├── tests ├── __init__.py ├── test_main.py ├── test_dependency_injection.py ├── test_pagination.py ├── test_modules │ ├── mocks │ │ └── async_client.py │ ├── test_versions.py │ ├── test_tariffs.py │ ├── test_cdrs.py │ ├── test_credentials.py │ ├── test_sessions.py │ ├── test_tokens.py │ └── test_commands.py └── test_push.py ├── examples ├── __init__.py └── v_2_2_1.py ├── py_ocpi ├── core │ ├── __init__.py │ ├── exceptions.py │ ├── config.py │ ├── schemas.py │ ├── dependencies.py │ ├── enums.py │ ├── utils.py │ ├── status.py │ ├── adapter.py │ ├── endpoints.py │ ├── crud.py │ ├── push.py │ └── data_types.py ├── modules │ ├── __init__.py │ ├── cdrs │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── cpo.py │ │ │ └── emsp.py │ │ │ ├── enums.py │ │ │ └── schemas.py │ ├── commands │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── emsp.py │ │ │ └── cpo.py │ │ │ ├── schemas.py │ │ │ └── enums.py │ ├── locations │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ │ └── api │ │ │ ├── __init__.py │ │ │ └── cpo.py │ ├── sessions │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── cpo.py │ │ │ └── emsp.py │ │ │ ├── schemas.py │ │ │ └── enums.py │ ├── tariffs │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── cpo.py │ │ │ └── emsp.py │ │ │ ├── schemas.py │ │ │ └── enums.py │ ├── tokens │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── emsp.py │ │ │ └── cpo.py │ │ │ ├── enums.py │ │ │ └── schemas.py │ ├── versions │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── main.py │ │ │ └── v_2_2_1.py │ │ ├── enums.py │ │ └── schemas.py │ └── credentials │ │ ├── __init__.py │ │ └── v_2_2_1 │ │ ├── enums.py │ │ ├── api │ │ ├── __init__.py │ │ ├── cpo.py │ │ └── emsp.py │ │ └── schemas.py ├── routers │ ├── __init__.py │ └── v_2_2_1 │ │ ├── cpo.py │ │ └── emsp.py ├── __init__.py └── main.py ├── docs ├── _config.yml ├── README.rst ├── tutorials │ ├── index.md │ ├── modules │ │ ├── index.md │ │ ├── cdrs.md │ │ ├── commands.md │ │ ├── tariffs.md │ │ ├── sessions.md │ │ ├── credentials.md │ │ ├── tokens.md │ │ └── locations.md │ ├── push.md │ ├── adapter.md │ └── crud.md ├── README.md ├── _sidebar.md ├── index.html ├── about │ └── index.md └── quick_start │ └── index.md ├── .github └── workflows │ ├── pypi.yaml │ └── push.yaml ├── pyproject.toml ├── LICENSE ├── .prospector.yaml ├── README.rst ├── makefile └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/cdrs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/locations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/sessions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/tariffs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/tokens/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-hacker -------------------------------------------------------------------------------- /py_ocpi/modules/credentials/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py_ocpi/modules/credentials/v_2_2_1/enums.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/README.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== -------------------------------------------------------------------------------- /py_ocpi/modules/versions/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import router 2 | from .v_2_2_1 import router as versions_v_2_2_1_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/cdrs/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/tariffs/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/tokens/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/commands/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/credentials/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/locations/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/modules/sessions/v_2_2_1/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .cpo import router as cpo_router 2 | from .emsp import router as emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from .v_2_2_1.cpo import router as v_2_2_1_cpo_router 2 | from .v_2_2_1.emsp import router as v_2_2_1_emsp_router 3 | -------------------------------------------------------------------------------- /py_ocpi/__init__.py: -------------------------------------------------------------------------------- 1 | """Python Implementation of OCPI""" 2 | 3 | __version__ = "0.3.1" 4 | 5 | from .core import enums, data_types 6 | from .main import get_application 7 | -------------------------------------------------------------------------------- /docs/tutorials/index.md: -------------------------------------------------------------------------------- 1 | # Tutorials 2 | 3 | This library uses the API defined by [OCPI](https://github.com/ocpi/ocpi). 4 | it's advised to check the API definition by OCPI before using this library. -------------------------------------------------------------------------------- /docs/tutorials/modules/index.md: -------------------------------------------------------------------------------- 1 | # Modules 2 | 3 | Each module in this library has a sequence for calling CRUD class methods by receiving OCPI requests. check each module to see how to CRUD class methods are used. -------------------------------------------------------------------------------- /py_ocpi/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class OCPIError(Exception): 2 | """ 3 | Generic Error 4 | """ 5 | 6 | 7 | class AuthorizationOCPIError(OCPIError): 8 | def __str__(self): 9 | return 'Your authorization token is invalid.' 10 | 11 | 12 | class NotFoundOCPIError(OCPIError): 13 | def __str__(self): 14 | return 'Object not found.' 15 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from py_ocpi import get_application 2 | from py_ocpi.core import enums 3 | from py_ocpi.modules.versions.enums import VersionNumber 4 | 5 | 6 | def test_get_application(): 7 | class Crud: 8 | ... 9 | 10 | class Adapter: 11 | ... 12 | 13 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 14 | 15 | assert app.url_path_for('get_versions') == "/ocpi/versions" 16 | -------------------------------------------------------------------------------- /py_ocpi/modules/versions/api/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from py_ocpi.core import status 4 | from py_ocpi.core.dependencies import get_versions as get_versions_ 5 | from py_ocpi.core.schemas import OCPIResponse 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("/versions", response_model=OCPIResponse) 11 | async def get_versions(versions=Depends(get_versions_)): 12 | return OCPIResponse( 13 | data=versions, 14 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 15 | ) 16 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | 6 | install Py-OCPI like this: 7 | 8 | ``` 9 | pip install py-ocpi 10 | ``` 11 | 12 | 13 | ## How Does it Work? 14 | 15 | 16 | Modules that communicate with central system will use crud for retrieving required data. the data that is retrieved from central system may 17 | not be compatible with OCPI protocol. So the data will be passed to adapter to make it compatible with schemas defined by OCPI. User only needs to 18 | modify crud and adapter based on central system architecture. 19 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to pypi 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | persist-credentials: false 15 | - uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.9' 18 | - name: Install poetry 19 | run: | 20 | pip install poetry 21 | - name: Publish 22 | run: | 23 | poetry publish --build --username ${{ secrets.PYPI_USER }} --password ${{ secrets.PYPI_PASSWORD }} 24 | -------------------------------------------------------------------------------- /tests/test_dependency_injection.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, MagicMock 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.modules.versions.enums import VersionNumber 8 | 9 | 10 | def test_inject_dependency(): 11 | crud = AsyncMock() 12 | crud.list.return_value = [], 0, True 13 | 14 | adapter = MagicMock() 15 | 16 | app = get_application([VersionNumber.v_2_2_1], [enums.RoleEnum.cpo], crud, adapter) 17 | 18 | client = TestClient(app) 19 | client.get('/ocpi/cpo/2.2.1/locations') 20 | 21 | crud.list.assert_awaited_once() 22 | -------------------------------------------------------------------------------- /docs/tutorials/modules/cdrs.md: -------------------------------------------------------------------------------- 1 | # CDRs 2 | 3 | Every CRUD method call from this module has _module\_id_ = cdrs, _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **GET** `/` 9 | 10 | crud.list is called with _filters_ argument containing _date\_from_, _date\_to_, _offset_ and _limit_ keys 11 | 12 | ## EMSP 13 | Every CRUD method call from this module has _role_ = EMSP 14 | 15 | - **GET** `/{cdr_id}` 16 | 17 | crud.get is called with _id_ = _cdr\_id_ 18 | 19 | - **PUT** `/` 20 | 21 | crud.create is called with data = dict (with standard OCPI CDR schema) 22 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * [HOME](./) 5 | 6 | - Getting started 7 | * [Quick Start](./quick_start/index) 8 | 9 | * [Tutorials](./tutorials/index) 10 | * [CRUD](./tutorials/crud) 11 | * [Adapter](./tutorials/adapter) 12 | * [Adapter](./tutorials/push) 13 | * [Modules](./tutorials/modules/index) 14 | * [Locations](./tutorials/modules/locations) 15 | * [Sessions](./tutorials/modules/sessions) 16 | * [Credentials](./tutorials/modules/credentials) 17 | * [Commands](./tutorials/modules/commands) 18 | * [CDRs](./tutorials/modules/cdrs) 19 | * [Tokens](./tutorials/modules/tokens) 20 | * [Tariffs](./tutorials/modules/tariffs) 21 | 22 | * [About](./about/index) 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "py_ocpi" 3 | version = "0.3.1" 4 | description = "Python Implementation of OCPI" 5 | authors = ["HAkhavan71 "] 6 | readme = "README.rst" 7 | homepage = "https://github.com/TECHS-Technological-Solutions/ocpi" 8 | license = "MIT" 9 | include = [ 10 | "LICENSE", 11 | ] 12 | keywords = ["ocpi"] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.9" 16 | fastapi = "^0.68.0" 17 | httpx = "^0.23.0" 18 | 19 | [tool.poetry.dev-dependencies] 20 | pytest = "^7.1" 21 | requests = "^2.7" 22 | pytest-cov = "^2.10.1" 23 | prospector = "^1.7.7" 24 | bandit = "^1.7.4" 25 | pytest-asyncio = "^0.20.3" 26 | 27 | [build-system] 28 | requires = ["poetry-core>=1.0.0"] 29 | build-backend = "poetry.core.masonry.api" 30 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, MagicMock 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.modules.versions.enums import VersionNumber 8 | 9 | 10 | def test_inject_dependency(): 11 | crud = AsyncMock() 12 | crud.list.return_value = [], 0, True 13 | 14 | adapter = MagicMock() 15 | 16 | app = get_application([VersionNumber.v_2_2_1], [enums.RoleEnum.cpo], crud, adapter) 17 | 18 | client = TestClient(app) 19 | response = client.get('/ocpi/cpo/2.2.1/locations') 20 | 21 | assert response.headers.get('X-Total-Count') == '0' 22 | assert response.headers.get('X-Limit') == '50' 23 | assert response.headers.get('Link') == '' 24 | -------------------------------------------------------------------------------- /py_ocpi/modules/credentials/v_2_2_1/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel 3 | 4 | from py_ocpi.core.data_types import CiString, URL, String 5 | from py_ocpi.core.enums import RoleEnum 6 | from py_ocpi.modules.locations.v_2_2_1.schemas import BusinessDetails 7 | 8 | 9 | class CredentialsRole(BaseModel): 10 | """ 11 | https://github.com/ocpi/ocpi/blob/2.2.1/credentials.asciidoc#141-credentialsrole-class 12 | """ 13 | role: RoleEnum 14 | business_details: BusinessDetails 15 | party_id: CiString(3) 16 | country_code: CiString(2) 17 | 18 | 19 | class Credentials(BaseModel): 20 | """ 21 | https://github.com/ocpi/ocpi/blob/2.2.1/credentials.asciidoc#131-credentials-object 22 | """ 23 | token: String(64) 24 | url: URL 25 | roles: List[CredentialsRole] 26 | -------------------------------------------------------------------------------- /docs/tutorials/push.md: -------------------------------------------------------------------------------- 1 | # Push 2 | 3 | push is a function to send(push) object data updates to the other end of OCPI communication. push function can be called manually, or via HTTP or Websocket endpoints. in order to add push endpoints either _http\_push_ or _websocket\_push_ must be set in _get\_application_. 4 | 5 | ## Push Function 6 | - **_input_**: 7 | 8 | version: The version number of the caller OCPI 9 | 10 | push: push request in Push schema 11 | 12 | crud: the CRUD class 13 | 14 | adapter: the Adapter class 15 | 16 | auth_token: The authentication token used by third party 17 | 18 | - **_output_**: push response in PushResponse schema 19 | 20 | ## Push Endpoint 21 | 22 | - **POST** `/{version}` 23 | 24 | version: The version number of the caller OCPI 25 | 26 | request body: push request in Push schema 27 | -------------------------------------------------------------------------------- /py_ocpi/core/config.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from pydantic import AnyHttpUrl, BaseSettings, validator 4 | 5 | 6 | class Settings(BaseSettings): 7 | PROJECT_NAME: str = 'OCPI' 8 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] 9 | OCPI_HOST: str = 'www.example.com' 10 | OCPI_PREFIX: str = 'ocpi' 11 | PUSH_PREFIX: str = 'push' 12 | COUNTRY_CODE: str = 'US' 13 | PARTY_ID: str = 'NON' 14 | 15 | @classmethod 16 | @validator("BACKEND_CORS_ORIGINS", pre=True) 17 | def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: 18 | if isinstance(v, str) and not v.startswith("["): 19 | return [i.strip() for i in v.split(",")] 20 | if isinstance(v, (list, str)): 21 | return v 22 | raise ValueError(v) 23 | 24 | class Config: 25 | case_sensitive = True 26 | env_file = ".env" 27 | 28 | 29 | settings = Settings() 30 | -------------------------------------------------------------------------------- /py_ocpi/modules/versions/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class VersionNumber(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/version_information_endpoint.asciidoc#125-versionnumber-enum 7 | """ 8 | v_2_0 = '2.0' 9 | v_2_1 = '2.1' 10 | v_2_1_1 = '2.1.1' 11 | v_2_2 = '2.2' 12 | v_2_2_1 = '2.2.1' 13 | latest = '2.2.1' 14 | 15 | 16 | class InterfaceRole(str, Enum): 17 | """ 18 | https://github.com/ocpi/ocpi/blob/2.2.1/version_information_endpoint.asciidoc#123-interfacerole-enum 19 | """ 20 | # Sender Interface implementation. 21 | # Interface implemented by the owner of data, 22 | # so the Receiver can Pull information from the data Sender/owner. 23 | sender = 'SENDER' 24 | # Receiver Interface implementation. 25 | # Interface implemented by the receiver of data, 26 | # so the Sender/owner can Push information to the Receiver. 27 | receiver = 'RECEIVER' 28 | -------------------------------------------------------------------------------- /py_ocpi/modules/versions/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from py_ocpi.modules.versions.enums import VersionNumber, InterfaceRole 6 | from py_ocpi.core.data_types import URL 7 | from py_ocpi.core.enums import ModuleID 8 | 9 | 10 | class Version(BaseModel): 11 | """ 12 | https://github.com/ocpi/ocpi/blob/2.2.1/version_information_endpoint.asciidoc#121-data 13 | """ 14 | version: VersionNumber 15 | url: URL 16 | 17 | 18 | class Endpoint(BaseModel): 19 | """ 20 | https://github.com/ocpi/ocpi/blob/2.2.1/version_information_endpoint.asciidoc#122-endpoint-class 21 | """ 22 | identifier: ModuleID 23 | role: InterfaceRole 24 | url: URL 25 | 26 | 27 | class VersionDetail(BaseModel): 28 | """ 29 | https://github.com/ocpi/ocpi/blob/2.2.1/version_information_endpoint.asciidoc#121-data 30 | """ 31 | version: VersionNumber 32 | endpoints: List[Endpoint] 33 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Py-OCPI Documentation 11 | 12 | 13 |
14 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /py_ocpi/core/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import List, Union 3 | 4 | from pydantic import BaseModel 5 | 6 | from py_ocpi.core.data_types import String, DateTime, URL 7 | from py_ocpi.core.enums import ModuleID 8 | 9 | 10 | class OCPIResponse(BaseModel): 11 | """ 12 | https://github.com/ocpi/ocpi/blob/2.2.1/transport_and_format.asciidoc#117-response-format 13 | """ 14 | data: Union[list, dict] 15 | status_code: int 16 | status_message: String(255) 17 | timestamp: DateTime = str(datetime.now(timezone.utc)) 18 | 19 | 20 | class Receiver(BaseModel): 21 | endpoints_url: URL 22 | auth_token: str 23 | 24 | 25 | class Push(BaseModel): 26 | module_id: ModuleID 27 | object_id: str 28 | receivers: List[Receiver] 29 | 30 | 31 | class ReceiverResponse(BaseModel): 32 | endpoints_url: URL 33 | status_code: int 34 | response: dict 35 | 36 | 37 | class PushResponse(BaseModel): 38 | receiver_responses: List[ReceiverResponse] 39 | -------------------------------------------------------------------------------- /docs/about/index.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | 4 | The Open Charge Point Interface (OCPI) enables a scalable, automated EV roaming setup between Charge Point Operators and e-Mobility Service Providers. It supports authorization, charge point information exchange (including live status updates and transaction events), charge detail record exchange, remote charge point commands and the exchange of smart-charging related information between parties. 5 | 6 | It offers market participants in EV an attractive and scalable solution for (international) roaming between networks, avoiding the costs and innovation-limiting complexities involved with today’s non-automated solutions or with central roaming hubs. As such it helps to enable EV drivers to charge everywhere in a fully-informed way, helps the market to develop quickly and helps market players to execute their business models in the best way. 7 | [read more](https://github.com/ocpi/ocpi/blob/master/introduction.asciidoc#introduction-and-background) 8 | 9 | This Library is a Python implementation OCPI by [Techs](https://itstartechs.com/) -------------------------------------------------------------------------------- /docs/tutorials/modules/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | Every CRUD method call from this module has _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **POST** `/{command_type}` 9 | 10 | if _location\_id_ is present in request body crud.get is called with _module\_id_ = locations and _id_ = _location\_id_ 11 | 12 | crud.do is called with _action_ = 'SendCommand', _data_ = dict (with standard OCPI Command schema based on command type), _command_ = _command\_type_ and _module\_id_ = commands 13 | 14 | crud.do is called with _action_ = 'GetClientToken' and _module\_id_ = commands 15 | 16 | crud.get is called every 2s for 5min to get command result with _command_ = _command\_type_ and _id_ = 0 (ignore _id_) 17 | 18 | ## EMSP 19 | Every CRUD method call from this module has _role_ = EMSP 20 | 21 | - **POST** `/{uid}` 22 | 23 | crud.update is called with _module\_id_ = commands, _data_ = dict (with standard OCPI CommandResult schema) and _id_ = _uid_ 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TECHS-Technological-Solutions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /py_ocpi/core/dependencies.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fastapi import Query 4 | 5 | from py_ocpi.core.adapter import Adapter 6 | from py_ocpi.core.config import settings 7 | from py_ocpi.core.crud import Crud 8 | from py_ocpi.core.data_types import URL 9 | from py_ocpi.modules.versions.enums import VersionNumber 10 | from py_ocpi.modules.versions.schemas import Version 11 | 12 | 13 | def get_crud(): 14 | return Crud 15 | 16 | 17 | def get_adapter(): 18 | return Adapter 19 | 20 | 21 | def get_versions(): 22 | return [ 23 | Version( 24 | version=VersionNumber.v_2_2_1, 25 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/{VersionNumber.v_2_2_1.value}/details') 26 | ).dict(), 27 | ] 28 | 29 | 30 | def get_endpoints(): 31 | return {} 32 | 33 | 34 | def pagination_filters( 35 | date_from: datetime = Query(default=None), 36 | date_to: datetime = Query(default=datetime.now()), 37 | offset: int = Query(default=0), 38 | limit: int = Query(default=50), 39 | ): 40 | return { 41 | 'date_from': date_from, 42 | 'date_to': date_to, 43 | 'offset': offset, 44 | 'limit': limit, 45 | } 46 | -------------------------------------------------------------------------------- /py_ocpi/routers/v_2_2_1/cpo.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from py_ocpi.modules.credentials.v_2_2_1.api import cpo_router as credentials_cpo_2_2_1_router 4 | from py_ocpi.modules.locations.v_2_2_1.api import cpo_router as locations_cpo_2_2_1_router 5 | from py_ocpi.modules.sessions.v_2_2_1.api import cpo_router as sessions_cpo_2_2_1_router 6 | from py_ocpi.modules.commands.v_2_2_1.api import cpo_router as commands_cpo_2_2_1_router 7 | from py_ocpi.modules.tariffs.v_2_2_1.api import cpo_router as tariffs_cpo_2_2_1_router 8 | from py_ocpi.modules.tokens.v_2_2_1.api import cpo_router as tokens_cpo_2_2_1_router 9 | from py_ocpi.modules.cdrs.v_2_2_1.api import cpo_router as cdrs_cpo_2_2_1_router 10 | 11 | 12 | router = APIRouter( 13 | ) 14 | router.include_router( 15 | locations_cpo_2_2_1_router 16 | ) 17 | router.include_router( 18 | credentials_cpo_2_2_1_router 19 | ) 20 | router.include_router( 21 | sessions_cpo_2_2_1_router 22 | ) 23 | router.include_router( 24 | commands_cpo_2_2_1_router 25 | ) 26 | router.include_router( 27 | tariffs_cpo_2_2_1_router 28 | ) 29 | router.include_router( 30 | tokens_cpo_2_2_1_router 31 | ) 32 | router.include_router( 33 | cdrs_cpo_2_2_1_router 34 | ) 35 | -------------------------------------------------------------------------------- /py_ocpi/modules/commands/v_2_2_1/api/emsp.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Request 2 | 3 | from py_ocpi.core.dependencies import get_crud, get_adapter 4 | from py_ocpi.core.enums import ModuleID, RoleEnum 5 | from py_ocpi.core.schemas import OCPIResponse 6 | from py_ocpi.core.adapter import Adapter 7 | from py_ocpi.core.crud import Crud 8 | from py_ocpi.core import status 9 | from py_ocpi.core.utils import get_auth_token 10 | from py_ocpi.modules.versions.enums import VersionNumber 11 | from py_ocpi.modules.commands.v_2_2_1.schemas import CommandResult 12 | 13 | router = APIRouter( 14 | prefix='/commands', 15 | ) 16 | 17 | 18 | @router.post("/{uid}", response_model=OCPIResponse) 19 | async def receive_command_result(request: Request, uid: str, command_result: CommandResult, 20 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 21 | auth_token = get_auth_token(request) 22 | 23 | await crud.update(ModuleID.commands, RoleEnum.emsp, command_result.dict(), uid, 24 | auth_token=auth_token, version=VersionNumber.v_2_2_1) 25 | 26 | return OCPIResponse( 27 | data=[], 28 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 29 | ) 30 | -------------------------------------------------------------------------------- /py_ocpi/routers/v_2_2_1/emsp.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from py_ocpi.modules.credentials.v_2_2_1.api import emsp_router as credentials_emsp_2_2_1_router 4 | from py_ocpi.modules.locations.v_2_2_1.api import emsp_router as locations_emsp_2_2_1_router 5 | from py_ocpi.modules.sessions.v_2_2_1.api import emsp_router as sessions_emsp_2_2_1_router 6 | from py_ocpi.modules.cdrs.v_2_2_1.api import emsp_router as cdrs_emsp_2_2_1_router 7 | from py_ocpi.modules.tariffs.v_2_2_1.api import emsp_router as tariffs_emsp_2_2_1_router 8 | from py_ocpi.modules.commands.v_2_2_1.api import emsp_router as commands_emsp_2_2_1_router 9 | from py_ocpi.modules.tokens.v_2_2_1.api import emsp_router as tokens_emsp_2_2_1_router 10 | 11 | 12 | router = APIRouter( 13 | ) 14 | router.include_router( 15 | locations_emsp_2_2_1_router 16 | ) 17 | router.include_router( 18 | credentials_emsp_2_2_1_router 19 | ) 20 | router.include_router( 21 | sessions_emsp_2_2_1_router 22 | ) 23 | router.include_router( 24 | cdrs_emsp_2_2_1_router 25 | ) 26 | router.include_router( 27 | tariffs_emsp_2_2_1_router 28 | ) 29 | router.include_router( 30 | commands_emsp_2_2_1_router 31 | ) 32 | router.include_router( 33 | tokens_emsp_2_2_1_router 34 | ) 35 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | inherits: 2 | - strictness_veryhigh 3 | - full_pep8 4 | 5 | ignore-paths: 6 | - bin 7 | - config 8 | - docs 9 | - coverage 10 | - requirements 11 | - venv 12 | - env 13 | - node_modules 14 | - manage.py 15 | - alembic 16 | 17 | doc-warnings: false 18 | test-warnings: true 19 | 20 | uses: 21 | - celery 22 | 23 | pylint: 24 | disable: 25 | - wildcard-import 26 | - relative-import 27 | - invalid-name 28 | - unused-wildcard-import 29 | - wrong-import-position 30 | - too-few-public-methods 31 | - old-style-class 32 | - no-init 33 | - no-self-use 34 | - unused-argument 35 | - too-many-arguments 36 | - too-many-instance-attributes 37 | - attribute-defined-outside-init 38 | - redefined-builtin 39 | - too-many-ancestors 40 | - arguments-differ 41 | - abstract-method 42 | - too-many-function-args 43 | - assignment-from-none 44 | - redefined-outer-name 45 | - no-self-argument 46 | options: 47 | max-locals: 25 48 | max-line-length: 120 49 | 50 | pycodestyle: 51 | disable: 52 | - E402 53 | - N805 54 | options: 55 | max-line-length: 120 56 | 57 | pyflakes: 58 | disable: 59 | - F403 60 | - F401 61 | - F999 -------------------------------------------------------------------------------- /py_ocpi/modules/cdrs/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Response, Request 2 | 3 | from py_ocpi.modules.versions.enums import VersionNumber 4 | from py_ocpi.core.utils import get_auth_token, get_list 5 | from py_ocpi.core import status 6 | from py_ocpi.core.schemas import OCPIResponse 7 | from py_ocpi.core.adapter import Adapter 8 | from py_ocpi.core.crud import Crud 9 | from py_ocpi.core.enums import ModuleID, RoleEnum 10 | from py_ocpi.core.dependencies import get_crud, get_adapter, pagination_filters 11 | 12 | router = APIRouter( 13 | prefix='/cdrs', 14 | ) 15 | 16 | 17 | @router.get("/", response_model=OCPIResponse) 18 | async def get_cdrs(response: Response, 19 | request: Request, 20 | crud: Crud = Depends(get_crud), 21 | adapter: Adapter = Depends(get_adapter), 22 | filters: dict = Depends(pagination_filters)): 23 | auth_token = get_auth_token(request) 24 | 25 | data_list = await get_list(response, filters, ModuleID.cdrs, RoleEnum.cpo, 26 | VersionNumber.v_2_2_1, crud, auth_token=auth_token) 27 | 28 | cdrs = [] 29 | for data in data_list: 30 | cdrs.append(adapter.cdr_adapter(data).dict()) 31 | return OCPIResponse( 32 | data=cdrs, 33 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 34 | ) 35 | -------------------------------------------------------------------------------- /py_ocpi/modules/versions/api/v_2_2_1.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Request, HTTPException, status as fastapistatus 2 | 3 | from py_ocpi.modules.versions.schemas import VersionDetail 4 | from py_ocpi.modules.versions.enums import VersionNumber 5 | from py_ocpi.core.crud import Crud 6 | from py_ocpi.core import status 7 | from py_ocpi.core.schemas import OCPIResponse 8 | from py_ocpi.core.dependencies import get_endpoints, get_crud 9 | from py_ocpi.core.utils import get_auth_token 10 | from py_ocpi.core.enums import Action, ModuleID 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/2.2.1/details", response_model=OCPIResponse) 15 | async def get_version_details(request: Request, endpoints=Depends(get_endpoints), 16 | crud: Crud = Depends(get_crud)): 17 | auth_token = get_auth_token(request) 18 | 19 | server_cred = await crud.do(ModuleID.credentials_and_registration, None, Action.get_client_token, 20 | auth_token=auth_token) 21 | if server_cred is None: 22 | raise HTTPException(fastapistatus.HTTP_401_UNAUTHORIZED, "Unauthorized") 23 | 24 | return OCPIResponse( 25 | data=VersionDetail( 26 | version=VersionNumber.v_2_2_1, 27 | endpoints=endpoints[VersionNumber.v_2_2_1] 28 | ).dict(), 29 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 30 | ) 31 | -------------------------------------------------------------------------------- /py_ocpi/modules/tariffs/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Response, Request 2 | 3 | from py_ocpi.core.utils import get_list, get_auth_token 4 | from py_ocpi.core import status 5 | from py_ocpi.core.schemas import OCPIResponse 6 | from py_ocpi.core.adapter import Adapter 7 | from py_ocpi.core.crud import Crud 8 | from py_ocpi.core.enums import ModuleID, RoleEnum 9 | from py_ocpi.core.dependencies import get_crud, get_adapter, pagination_filters 10 | from py_ocpi.modules.versions.enums import VersionNumber 11 | 12 | router = APIRouter( 13 | prefix='/tariffs', 14 | ) 15 | 16 | 17 | @router.get("/", response_model=OCPIResponse) 18 | async def get_tariffs(request: Request, 19 | response: Response, 20 | crud: Crud = Depends(get_crud), 21 | adapter: Adapter = Depends(get_adapter), 22 | filters: dict = Depends(pagination_filters)): 23 | auth_token = get_auth_token(request) 24 | 25 | data_list = await get_list(response, filters, ModuleID.tariffs, RoleEnum.cpo, 26 | VersionNumber.v_2_2_1, crud, auth_token=auth_token) 27 | 28 | tariffs = [] 29 | for data in data_list: 30 | tariffs.append(adapter.tariff_adapter(data).dict()) 31 | return OCPIResponse( 32 | data=tariffs, 33 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 34 | ) 35 | -------------------------------------------------------------------------------- /docs/tutorials/modules/tariffs.md: -------------------------------------------------------------------------------- 1 | # Tariffs 2 | 3 | Every CRUD method call from this module has _module\_id_ = tariffs, _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **GET** `/` 9 | 10 | crud.list is called with _filters_ argument containing _date\_from_, _date\_to_, _offset_ and _limit_ keys 11 | 12 | ## EMSP 13 | Every CRUD method call from this module has _role_ = EMSP 14 | 15 | - **GET** `/{country_code}/{party_id}/{tariff_id}` 16 | 17 | crud.get is called with _id_ = _tariff\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 18 | 19 | - **PUT** `/{country_code}/{party_id}/{tariff_id}` 20 | 21 | crud.get is called with _id_ = _tariff\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 22 | 23 | if object exists crud.update is called with _id_ = _tariff\_id_, data = dict (with standard OCPI Tariff schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 24 | 25 | if object doesn't exist crud.create is called with data = dict (with standard OCPI Tariff schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 26 | 27 | - **DELETE** `/{country_code}/{party_id}/{tariff_id}` 28 | 29 | crud.delete is called with _id_ = _tariff\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 30 | -------------------------------------------------------------------------------- /py_ocpi/core/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class RoleEnum(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/types.asciidoc#151-role-enum 7 | """ 8 | # Charge Point Operator Role 9 | cpo = 'CPO' 10 | # eMobility Service Provider Role 11 | emsp = 'EMSP' 12 | # Hub role 13 | hub = 'HUB' 14 | # National Access Point Role (national Database with all Location information of a country) 15 | nap = 'NAP' 16 | # Navigation Service Provider Role, role like an eMSP (probably only interested in Location information) 17 | nsp = 'NSP' 18 | # Other role 19 | other = 'OTHER' 20 | # Smart Charging Service Provider Role 21 | scsp = 'SCSP' 22 | 23 | 24 | class ModuleID(str, Enum): 25 | """ 26 | https://github.com/ocpi/ocpi/blob/2.2.1/version_information_endpoint.asciidoc#124-moduleid-enum 27 | """ 28 | cdrs = 'cdrs' 29 | charging_profile = 'chargingprofiles' 30 | commands = 'commands' 31 | credentials_and_registration = 'credentials' 32 | hub_client_info = 'hubclientinfo' 33 | locations = 'locations' 34 | sessions = 'sessions' 35 | tariffs = 'tariffs' 36 | tokens = 'tokens' 37 | 38 | 39 | class Action(str, Enum): 40 | # used for requesting to send an OCPP command to a Chargepoint 41 | send_command = 'SendCommand' 42 | # used for getting client authentication token 43 | get_client_token = 'GetClientToken' # nosec 44 | # used for authorizing a token 45 | authorize_token = 'AuthorizeToken' # nosec 46 | -------------------------------------------------------------------------------- /py_ocpi/modules/tokens/v_2_2_1/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class AllowedType(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#141-allowedtype-enum 7 | """ 8 | # This Token is allowed to charge (at this location). 9 | allowed = 'ALLOWED' 10 | # This Token is blocked. 11 | blocked = 'BLOCKED' 12 | # This Token has expired. 13 | expired = 'EXPIRED' 14 | # This Token belongs to an account that has not enough credits to charge (at the given location). 15 | no_credit = 'NO_CREDIT' 16 | # Token is valid, but is not allowed to charge at the given location. 17 | not_allowed = 'NOT_ALLOWED' 18 | 19 | 20 | class TokenType(str, Enum): 21 | """ 22 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#144-tokentype-enum 23 | """ 24 | # One time use Token ID generated by a server (or App.) 25 | # The eMSP uses this to bind a Session to a customer, probably an app user. 26 | ad_hoc_user = 'AD_HOC_USER' 27 | # Token ID generated by a server (or App.) to identify a user of an App. 28 | # The same user uses the same Token for every Session. 29 | app_user = 'APP_USER' 30 | # Other type of token 31 | other = 'OTHER' 32 | # RFID Token 33 | rfid = 'RFID' 34 | 35 | 36 | class WhitelistType(str, Enum): 37 | """ 38 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#145-whitelisttype-enum 39 | """ 40 | always = 'ALWAYS' 41 | allowed = 'ALLOWED' 42 | allowed_offline = 'ALLOWED_OFFLINE' 43 | never = 'NEVER' 44 | -------------------------------------------------------------------------------- /tests/test_modules/mocks/async_client.py: -------------------------------------------------------------------------------- 1 | from py_ocpi.core.dependencies import get_versions 2 | from py_ocpi.core.endpoints import ENDPOINTS 3 | from py_ocpi.core.enums import RoleEnum 4 | from py_ocpi.modules.versions.enums import VersionNumber 5 | from py_ocpi.modules.versions.schemas import VersionDetail 6 | 7 | fake_endpoints_data = { 8 | 'data': [ 9 | VersionDetail( 10 | version=VersionNumber.v_2_2_1, 11 | endpoints=ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.cpo] 12 | ).dict(), 13 | ], 14 | } 15 | 16 | fake_versions_data = { 17 | 'data': get_versions() 18 | } 19 | 20 | 21 | class MockResponse: 22 | def __init__(self, json_data, status_code): 23 | self.json_data = json_data 24 | self.status_code = status_code 25 | 26 | def json(self): 27 | return self.json_data 28 | 29 | 30 | # Connector mocks 31 | 32 | class MockAsyncClientVersionsAndEndpoints: 33 | async def get(url, headers): 34 | if url == 'versions_url': 35 | return MockResponse(fake_versions_data, 200) 36 | else: 37 | return MockResponse(fake_endpoints_data, 200) 38 | 39 | def build_request(self, request, headers, json): 40 | return self 41 | 42 | async def send(request): 43 | return MockResponse(fake_endpoints_data, 200) 44 | 45 | 46 | class MockAsyncClientGeneratorVersionsAndEndpoints: 47 | 48 | async def __aenter__(self): 49 | return MockAsyncClientVersionsAndEndpoints 50 | 51 | async def __aexit__(self, *args, **kwargs): 52 | pass 53 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | .. image:: https://img.shields.io/pypi/v/py-ocpi.svg?style=flat 3 | :target: https://pypi.org/project/py-ocpi/ 4 | .. image:: https://pepy.tech/badge/py-ocpi/month 5 | :target: https://pepy.tech/project/py-ocpi 6 | .. image:: https://github.com/TECHS-Technological-Solutions/ocpi/actions/workflows/pypi.yaml/badge.svg?style=svg 7 | :target: https://github.com/TECHS-Technological-Solutions/ocpi/actions?query=workflow:pypi 8 | .. image:: https://coveralls.io/repos/github/TECHS-Technological-Solutions/ocpi/badge.svg 9 | :target: https://coveralls.io/github/TECHS-Technological-Solutions/ocpi 10 | 11 | Introduction 12 | ============ 13 | 14 | This Library is a Python implementation of the Open Charge Point Interface (OCPI) 15 | 16 | 17 | Getting Started 18 | =============== 19 | 20 | Installation 21 | ------------ 22 | 23 | install Py-OCPI like this: 24 | 25 | .. code-block:: bash 26 | 27 | pip install py-ocpi 28 | 29 | 30 | How Does it Work? 31 | ----------------- 32 | 33 | Modules that communicate with central system will use crud for retrieving required data. the data that is retrieved from central system may 34 | not be compatible with OCPI protocol. So the data will be passed to adapter to make it compatible with schemas defined by OCPI. User only needs to 35 | modify crud and adapter based on central system architecture. 36 | 37 | Example 38 | ------- 39 | 40 | https://github.com/TECHS-Technological-Solutions/ocpi/blob/830dba5fb3bbc7297326a4963429d7a9f850f28d/examples/v_2_2_1.py#L1-L205 41 | 42 | Documents 43 | --------- 44 | 45 | Check the `Documentation `_ 46 | 47 | 48 | License 49 | ======= 50 | 51 | This project is licensed under the terms of the MIT license. 52 | -------------------------------------------------------------------------------- /tests/test_modules/test_versions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi.main import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.core.crud import Crud 8 | from py_ocpi.core.adapter import Adapter 9 | from py_ocpi.modules.versions.enums import VersionNumber 10 | from py_ocpi.core.enums import ModuleID, RoleEnum, Action 11 | 12 | 13 | def test_get_versions(): 14 | 15 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 16 | 17 | client = TestClient(app) 18 | response = client.get('/ocpi/versions') 19 | 20 | assert response.status_code == 200 21 | assert len(response.json()['data']) == 1 22 | 23 | 24 | def test_get_versions_v_2_2_1(): 25 | token = None 26 | class MockCrud(Crud): 27 | @classmethod 28 | async def do(cls, module: ModuleID, role: RoleEnum, action: Action, auth_token, *args, data: dict = None, **kwargs) -> Any: 29 | nonlocal token 30 | token = auth_token 31 | return {} 32 | 33 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], MockCrud, Adapter) 34 | 35 | client = TestClient(app) 36 | response = client.get('/ocpi/2.2.1/details', headers={ 37 | 'authorization': 'Token Zm9v' 38 | }) 39 | 40 | assert response.status_code == 200 41 | assert response.json()['data']['version'] == '2.2.1' 42 | assert token == 'foo' 43 | 44 | 45 | def test_get_versions_v_2_2_1_requires_auth(): 46 | 47 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 48 | 49 | client = TestClient(app) 50 | response = client.get('/ocpi/2.2.1/details') 51 | 52 | assert response.status_code == 401 53 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: CI tests on push 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'develop' 7 | - 'feature/**' 8 | - 'fix/**' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | persist-credentials: false 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: '3.9' 20 | - name: Setup poetry 21 | run: | 22 | sudo -H pip install -U poetry 23 | - name: Install packages 24 | run: | 25 | make install 26 | - name: Run linter 27 | run: | 28 | poetry run prospector ./py_ocpi --profile .prospector.yaml 29 | test: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v2 33 | with: 34 | persist-credentials: false 35 | - uses: actions/setup-python@v2 36 | with: 37 | python-version: '3.9' 38 | - name: Setup poetry 39 | run: | 40 | sudo -H pip install -U poetry 41 | - name: Install packages 42 | run: | 43 | make install 44 | - name: Run test suite 45 | run: | 46 | poetry run pytest -v 47 | security: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v2 51 | with: 52 | persist-credentials: false 53 | - uses: actions/setup-python@v2 54 | with: 55 | python-version: '3.9' 56 | - name: Setup poetry 57 | run: | 58 | sudo -H pip install -U poetry 59 | - name: Install packages 60 | run: | 61 | make install 62 | - name: Run bandit 63 | run: | 64 | poetry run bandit -r ./py_ocpi 65 | -------------------------------------------------------------------------------- /docs/tutorials/modules/sessions.md: -------------------------------------------------------------------------------- 1 | # Sessions 2 | 3 | Every CRUD method call from this module has _module\_id_ = sessions, _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **GET** `/` 9 | 10 | crud.list is called with _filters_ argument containing _date\_from_, _date\_to_, _offset_ and _limit_ keys 11 | 12 | - **PUT** `/{session_id}/charging_preferences` 13 | 14 | crud.update is called with _id_ = _session\_id_ and data = dict (with standard OCPI Charging Preference schema) 15 | 16 | ## EMSP 17 | Every CRUD method call from this module has _role_ = EMSP 18 | 19 | - **GET** `/{country_code}/{party_id}/{session_id}` 20 | 21 | crud.get is called with _id_ = _session\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 22 | 23 | - **PUT** `/{country_code}/{party_id}/{session_id}` 24 | 25 | crud.get is called with _id_ = _session\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 26 | 27 | if object exists crud.update is called with _id_ = _session\_id_, data = dict (with standard OCPI Session schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 28 | 29 | if object doesn't exist crud.create is called with data = dict (with standard OCPI Session schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 30 | 31 | - **PATCH** `/{country_code}/{party_id}/{session_id}` 32 | 33 | crud.get is called with _id_ = _session\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 34 | 35 | crud.update is called with _id_ = _session\_id_, data = dict (with standard OCPI Session schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 36 | -------------------------------------------------------------------------------- /docs/tutorials/modules/credentials.md: -------------------------------------------------------------------------------- 1 | # Credentials 2 | 3 | Every CRUD method call from this module has _module\_id_ = credentials, _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **GET** `/` 9 | 10 | crud.get is called with _id_ = {token used in HTTP request header} 11 | 12 | - **post** `/` 13 | 14 | crud.do is called with _action_ = 'GetClientToken' and _module\_id_ = credentials 15 | 16 | crud.create is called with data = dict (with keys 'credentials': the request body with OCPI Credentials schema and 'endpoints': the response from client version details) 17 | 18 | - **put** `/` 19 | 20 | crud.do is called with _action_ = 'GetClientToken' and _module\_id_ = credentials 21 | 22 | crud.update is called with data = dict (with keys 'credentials': the request body with OCPI Credentials schema and 'endpoints': the response from client version details) 23 | 24 | ## EMSP 25 | Every CRUD method call from this module has _role_ = EMSP 26 | 27 | - **GET** `/` 28 | 29 | crud.get is called with _id_ = {token used in HTTP request header} 30 | 31 | - **post** `/` 32 | 33 | crud.do is called with _action_ = 'GetClientToken' and _module\_id_ = credentials 34 | 35 | crud.create is called with data = dict (with keys 'credentials': the request body with OCPI Credentials schema and 'endpoints': the response from client version details) 36 | 37 | - **put** `/` 38 | 39 | crud.do is called with _action_ = 'GetClientToken' and _module\_id_ = credentials 40 | 41 | crud.update is called with data = dict (with keys 'credentials': the request body with OCPI Credentials schema and 'endpoints': the response from client version details) 42 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help .check-pypi-envs .install-poetry update install tests build deploy 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | export PATH := ${HOME}/.local/bin:$(PATH) 6 | 7 | IS_POETRY := $(shell pip freeze | grep "poetry==") 8 | 9 | CURRENT_VERSION := $(shell poetry version -s) 10 | 11 | help: 12 | @echo "Please use 'make ', where is one of" 13 | @echo "" 14 | @echo " build builds the project .whl with poetry" 15 | @echo " deploy deploys the project using poetry (not recommended, only use if really needed)" 16 | @echo " help outputs this helper" 17 | @echo " install installs the dependencies in the env" 18 | @echo " release version= bumps the project version to , using poetry;" 19 | @echo " Updates also docs/source/conf.py version;" 20 | @echo " If no version is provided, poetry outputs the current project version" 21 | @echo " test run all the tests and linting" 22 | @echo " update updates the dependencies in poetry.lock" 23 | @echo "" 24 | @echo "Check the Makefile to know exactly what each target is doing." 25 | 26 | 27 | .install-poetry: 28 | @if [ -z ${IS_POETRY} ]; then pip install poetry; fi 29 | 30 | update: .install-poetry 31 | poetry update 32 | 33 | install: .install-poetry 34 | poetry install 35 | 36 | tests: .install-poetry 37 | poetry run flake8 ocpp tests 38 | poetry run py.test -vvv --cov=ocpp --cov-report=term-missing tests/ 39 | 40 | build: .install-poetry 41 | poetry build 42 | 43 | release: .install-poetry 44 | @echo "Please remember to update the CHANGELOG.md, before tagging the release" 45 | @sed -i ".bkp" "s/release = '${CURRENT_VERSION}'/release = '${version}'/g" docs/source/conf.py 46 | @poetry version ${version} 47 | 48 | deploy: update tests 49 | poetry publish --build -------------------------------------------------------------------------------- /docs/quick_start/index.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## CRUD 4 | Create a crud class with get, list, create, update, delete and do methods: 5 | 6 | ```python 7 | from py_ocpi.core.enums import ModuleID, RoleEnum 8 | 9 | 10 | class Crud: 11 | @classmethod 12 | async def get(cls, module: ModuleID, role: RoleEnum, id, *args, **kwargs) -> Any: 13 | ... 14 | 15 | @classmethod 16 | async def list(cls, module: ModuleID, role: RoleEnum, filters: dict, *args, **kwargs) -> Tuple[list, int, bool]: 17 | ... 18 | 19 | @classmethod 20 | async def create(cls, module: ModuleID, role: RoleEnum, data: dict, *args, **kwargs) -> Any: 21 | ... 22 | 23 | @classmethod 24 | async def update(cls, module: ModuleID, role: RoleEnum, data: dict, id, *args, **kwargs) -> Any: 25 | ... 26 | 27 | @classmethod 28 | async def delete(cls, module: ModuleID, role: RoleEnum, id, *args, **kwargs): 29 | ... 30 | 31 | @classmethod 32 | async def do(cls, module: ModuleID, role: RoleEnum, action: Action, *args, data: dict = None, **kwargs) -> Any: 33 | ... 34 | ``` 35 | 36 | ## Adapter 37 | Create a adapter class with an adapter method for each OCPI module. this example will only include the adapter for locations module. 38 | 39 | ```python 40 | from py_ocpi.modules.versions.enums import VersionNumber 41 | from py_ocpi.modules.locations.v_2_2_1.schemas import Location 42 | 43 | 44 | class Adapter: 45 | @classmethod 46 | def location_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 47 | return Location(**data) 48 | ``` 49 | 50 | ## Create Application 51 | Specify the OCPI versions and OCPI roles that are intended for the application and pass the already defined Crud and Adapter. 52 | 53 | ```python 54 | from py_ocpi import get_application 55 | 56 | app = get_application([VersionNumber.v_2_2_1], [enums.RoleEnum.cpo], Crud, Adapter) 57 | ``` 58 | 59 | ## Run Application 60 | The app is and instance of FastAPI app. you can run it like any FastAPI app. 61 | -------------------------------------------------------------------------------- /py_ocpi/core/utils.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | import base64 3 | 4 | from fastapi import Response, Request 5 | from pydantic import BaseModel 6 | 7 | from py_ocpi.core.enums import ModuleID, RoleEnum 8 | from py_ocpi.core.config import settings 9 | from py_ocpi.modules.versions.enums import VersionNumber 10 | 11 | 12 | def set_pagination_headers(response: Response, link: str, total: int, limit: int): 13 | response.headers['Link'] = link 14 | response.headers['X-Total-Count'] = str(total) 15 | response.headers['X-Limit'] = str(limit) 16 | return response 17 | 18 | 19 | def get_auth_token(request: Request) -> str: 20 | headers = request.headers 21 | headers_token = headers.get('authorization', 'Token Null') 22 | token = headers_token.split()[1] 23 | if token == 'Null': # nosec 24 | return None 25 | return decode_string_base64(token) 26 | 27 | 28 | async def get_list(response: Response, filters: dict, module: ModuleID, role: RoleEnum, 29 | version: VersionNumber, crud, *args, **kwargs): 30 | data_list, total, is_last_page = await crud.list(module, role, filters, *args, version=version, **kwargs) 31 | 32 | link = '' 33 | params = dict(**filters) 34 | params['offset'] = filters['offset'] + filters['limit'] 35 | if not is_last_page: 36 | link = (f'; rel="next"') 38 | 39 | set_pagination_headers(response, link, total, filters['limit']) 40 | 41 | return data_list 42 | 43 | 44 | def partially_update_attributes(instance: BaseModel, attributes: dict): 45 | for key, value in attributes.items(): 46 | setattr(instance, key, value) 47 | 48 | 49 | def encode_string_base64(input: str) -> str: 50 | input_bytes = base64.b64encode(bytes(input, 'utf-8')) 51 | return input_bytes.decode('utf-8') 52 | 53 | 54 | def decode_string_base64(input: str) -> str: 55 | input_bytes = base64.b64decode(bytes(input, 'utf-8')) 56 | return input_bytes.decode('utf-8') 57 | -------------------------------------------------------------------------------- /py_ocpi/core/status.py: -------------------------------------------------------------------------------- 1 | """ 2 | OCPI status codes based on https://github.com/ocpi/ocpi/blob/2.2.1/status_codes.asciidoc 3 | """ 4 | 5 | # 1xxx: Success 6 | OCPI_1000_GENERIC_SUCESS_CODE = { 7 | 'status_code': 1000, 8 | 'status_message': 'Generic success code' 9 | } 10 | 11 | # 2xxx: Client errors 12 | OCPI_2000_GENERIC_CLIENT_ERROR = { 13 | 'status_code': 2000, 14 | 'status_message': 'Generic client error' 15 | } 16 | OCPI_2001_INVALID_OR_MISSING_PARAMETERS = { 17 | 'status_code': 2001, 18 | 'status_message': 'Invalid or missing parameters' 19 | } 20 | OCPI_2002_NOT_ENOUGH_INFORMATION = { 21 | 'status_code': 2002, 22 | 'status_message': 'Not enough information' 23 | } 24 | OCPI_2003_UNKNOWN_LOCATION = { 25 | 'status_code': 2003, 26 | 'status_message': 'Unknown Location' 27 | } 28 | OCPI_2004_UNKNOWN_TOKEN = { 29 | 'status_code': 2004, 30 | 'status_message': 'Unknown Token' 31 | } 32 | 33 | # 3xxx: Server errors 34 | OCPI_3000_GENERIC_SERVER_ERROR = { 35 | 'status_code': 3000, 36 | 'status_message': 'Generic server error' 37 | } 38 | OCPI_3001_UNABLE_TO_USE_CLIENTS_API = { 39 | 'status_code': 3001, 40 | 'status_message': 'Unable to use the client’s API' 41 | } 42 | OCPI_3002_UNSUPPORTED_VERSION = { 43 | 'status_code': 3002, 44 | 'status_message': 'Unsupported version' 45 | } 46 | OCPI_3003_NO_MATCHING_ENDPOINT = { 47 | 'status_code': 3003, 48 | 'status_message': 'No matching endpoints or expected endpoints missing between parties' 49 | } 50 | 51 | # 4xxx: Hub errors 52 | OCPI_4000_GENERIC_ERROR = { 53 | 'status_code': 4000, 54 | 'status_message': 'Generic error' 55 | } 56 | OCPI_4001_UNKNOWN_RECEIVER = { 57 | 'status_code': 4001, 58 | 'status_message': 'Unknown receiver (TO address is unknown)' 59 | } 60 | OCPI_4002_TIMEOUT_ON_FORWARDED_REQUEST = { 61 | 'status_code': 4002, 62 | 'status_message': 'Timeout on forwarded request (message is forwarded, but request times out)' 63 | } 64 | OCPI_4003_CONNECTION_PROBLEM = { 65 | 'status_code': 4003, 66 | 'status_message': 'Connection problem (receiving party is not connected)' 67 | } 68 | -------------------------------------------------------------------------------- /py_ocpi/modules/cdrs/v_2_2_1/api/emsp.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Request, Response 2 | 3 | from py_ocpi.modules.cdrs.v_2_2_1.schemas import Cdr 4 | from py_ocpi.modules.versions.enums import VersionNumber 5 | from py_ocpi.core.utils import get_auth_token 6 | from py_ocpi.core import status 7 | from py_ocpi.core.schemas import OCPIResponse 8 | from py_ocpi.core.adapter import Adapter 9 | from py_ocpi.core.crud import Crud 10 | from py_ocpi.core.data_types import CiString 11 | from py_ocpi.core.enums import ModuleID, RoleEnum 12 | from py_ocpi.core.config import settings 13 | from py_ocpi.core.dependencies import get_crud, get_adapter 14 | 15 | router = APIRouter( 16 | prefix='/cdrs', 17 | ) 18 | 19 | 20 | @router.get("/{cdr_id}", response_model=OCPIResponse) 21 | async def get_cdr(request: Request, cdr_id: CiString(36), 22 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 23 | auth_token = get_auth_token(request) 24 | 25 | data = await crud.get(ModuleID.cdrs, RoleEnum.emsp, cdr_id, auth_token=auth_token, 26 | version=VersionNumber.v_2_2_1) 27 | return OCPIResponse( 28 | data=[adapter.cdr_adapter(data, VersionNumber.v_2_2_1).dict()], 29 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 30 | ) 31 | 32 | 33 | @router.post("/", response_model=OCPIResponse) 34 | async def add_cdr(request: Request, response: Response, cdr: Cdr, 35 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 36 | auth_token = get_auth_token(request) 37 | 38 | data = await crud.create(ModuleID.cdrs, RoleEnum.emsp, cdr.dict(), 39 | auth_token=auth_token, version=VersionNumber.v_2_2_1) 40 | 41 | cdr_data = adapter.cdr_adapter(data) 42 | cdr_url = (f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 43 | f'/{VersionNumber.v_2_2_1}/{ModuleID.cdrs}/{cdr_data.id}') 44 | response.headers.append('Location', cdr_url) 45 | 46 | return OCPIResponse( 47 | data=[cdr_data.dict()], 48 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 49 | ) 50 | -------------------------------------------------------------------------------- /docs/tutorials/modules/tokens.md: -------------------------------------------------------------------------------- 1 | # Tokens 2 | 3 | Every CRUD method call from this module has _module\_id_ = tokens, _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **GET** `/{country_code}/{party_id}/{token_uid}` 9 | 10 | crud.get is called with _id_ = _token\_uid_, _country\_code_ = _country\_code_, _party\_id_ = _party\_id_ and _token\_type_ = (_token\_type_ passed in query parameters) 11 | 12 | - **PUT** `/{country_code}/{party_id}/{token_uid}` 13 | 14 | crud.get is called with _id_ = _token\_uid_, _country\_code_ = _country\_code_, _party\_id_ = _party\_id_ and _token\_type_ = (_token\_type_ passed in query parameters) 15 | 16 | if object exists crud.update is called with _id_ = _token\_uid_, data = dict (with standard OCPI Token schema), _country\_code_ = _country\_code_, _party\_id_ = _party\_id_ and _token\_type_ = (_token\_type_ passed in query parameters) 17 | 18 | if object doesn't exist crud.create is called with data = dict (with standard OCPI Token schema), _country\_code_ = _country\_code_, _party\_id_ = _party\_id_ and _token\_type_ = (_token\_type_ passed in query parameters) 19 | 20 | - **PATCH** `/{country_code}/{party_id}/{token_uid}` 21 | 22 | crud.get is called with _id_ = _token\_uid_, _country\_code_ = _country\_code_, _party\_id_ = _party\_id_ and _token\_type_ = (_token\_type_ passed in query parameters) 23 | 24 | crud.update is called with _id_ = _token\_uid_, data = dict (with standard OCPI Token schema), _country\_code_ = _country\_code_, _party\_id_ = _party\_id_ and _token\_type_ = (_token\_type_ passed in query parameters) 25 | 26 | ## EMSP 27 | Every CRUD method call from this module has _role_ = EMSP 28 | 29 | - **GET** `/` 30 | 31 | crud.list is called with _filters_ argument containing _date\_from_, _date\_to_, _offset_ and _limit_ keys 32 | 33 | - **POST** `/{token_uid}/authorize` 34 | 35 | crud.get is called with _id_ = _token\_uid_ and _token\_type_ = (_token\_type_ passed in query parameters) 36 | 37 | crud.do is called with _action_ = 'AuthorizeToken', _data_ = dict (with keys 'token_uid': _token\_uid_, 'token_type': _token\_type_, 'location_reference': request body with standard OCPI LocationReference schema), 38 | -------------------------------------------------------------------------------- /py_ocpi/modules/tariffs/v_2_2_1/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from py_ocpi.modules.locations.v_2_2_1.schemas import EnergyMix 6 | from py_ocpi.modules.tariffs.v_2_2_1.enums import DayOfWeek, ReservationRestrictionType, TariffDimensionType, TariffType 7 | from py_ocpi.core.data_types import URL, CiString, DisplayText, Number, Price, String, DateTime 8 | 9 | 10 | class PriceComponent(BaseModel): 11 | """ 12 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#142-pricecomponent-class 13 | """ 14 | type: TariffDimensionType 15 | price: Number 16 | vat: Optional[Number] 17 | step_size: int 18 | 19 | 20 | class TariffRestrictions(BaseModel): 21 | """ 22 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#146-tariffrestrictions-class 23 | """ 24 | start_time: Optional[String(5)] 25 | end_time: Optional[String(5)] 26 | start_date: Optional[String(10)] 27 | end_date: Optional[String(10)] 28 | min_kwh: Optional[Number] 29 | max_kwh: Optional[Number] 30 | min_current: Optional[Number] 31 | max_current: Optional[Number] 32 | min_power: Optional[Number] 33 | max_power: Optional[Number] 34 | min_duration: Optional[int] 35 | max_duration: Optional[int] 36 | day_of_week: List[DayOfWeek] = [] 37 | reservation: Optional[ReservationRestrictionType] 38 | 39 | 40 | class TariffElement(BaseModel): 41 | """ 42 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#144-tariffelement-class 43 | """ 44 | price_components: List[PriceComponent] 45 | restrictions: Optional[TariffRestrictions] 46 | 47 | 48 | class Tariff(BaseModel): 49 | """ 50 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#131-tariff-object 51 | """ 52 | country_code: CiString(2) 53 | party_id: CiString(3) 54 | id: CiString(36) 55 | currency: String(3) 56 | type: Optional[TariffType] 57 | tariff_alt_text: List[DisplayText] = [] 58 | tariff_alt_url: Optional[URL] 59 | min_price: Optional[Price] 60 | max_price: Optional[Price] 61 | elements: List[TariffElement] 62 | start_date_time: Optional[DateTime] 63 | end_date_time: Optional[DateTime] 64 | energy_mix: Optional[EnergyMix] 65 | last_updated: DateTime 66 | -------------------------------------------------------------------------------- /py_ocpi/modules/sessions/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Response, Request 2 | 3 | from py_ocpi.modules.sessions.v_2_2_1.schemas import ChargingPreferences 4 | from py_ocpi.modules.versions.enums import VersionNumber 5 | from py_ocpi.core.utils import get_list, get_auth_token 6 | from py_ocpi.core import status 7 | from py_ocpi.core.schemas import OCPIResponse 8 | from py_ocpi.core.adapter import Adapter 9 | from py_ocpi.core.crud import Crud 10 | from py_ocpi.core.data_types import CiString 11 | from py_ocpi.core.enums import ModuleID, RoleEnum 12 | from py_ocpi.core.dependencies import get_crud, get_adapter, pagination_filters 13 | 14 | router = APIRouter( 15 | prefix='/sessions', 16 | ) 17 | 18 | 19 | @router.get("/", response_model=OCPIResponse) 20 | async def get_sessions(request: Request, 21 | response: Response, 22 | crud: Crud = Depends(get_crud), 23 | adapter: Adapter = Depends(get_adapter), 24 | filters: dict = Depends(pagination_filters)): 25 | auth_token = get_auth_token(request) 26 | 27 | data_list = await get_list(response, filters, ModuleID.sessions, RoleEnum.cpo, 28 | VersionNumber.v_2_2_1, crud, auth_token=auth_token) 29 | 30 | sessions = [] 31 | for data in data_list: 32 | sessions.append(adapter.session_adapter(data).dict()) 33 | return OCPIResponse( 34 | data=sessions, 35 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 36 | ) 37 | 38 | 39 | @router.put("/{session_id}/charging_preferences", response_model=OCPIResponse) 40 | async def set_charging_preference(request: Request, 41 | session_id: CiString(36), 42 | charging_preferences: ChargingPreferences, 43 | crud: Crud = Depends(get_crud), 44 | adapter: Adapter = Depends(get_adapter)): 45 | auth_token = get_auth_token(request) 46 | data = await crud.update(ModuleID.sessions, RoleEnum.cpo, charging_preferences.dict(), session_id, 47 | auth_token=auth_token, version=VersionNumber.v_2_2_1) 48 | return OCPIResponse( 49 | data=[adapter.charging_preference_adapter(data).dict()], 50 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 51 | ) 52 | -------------------------------------------------------------------------------- /py_ocpi/modules/commands/v_2_2_1/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | 4 | from py_ocpi.core.data_types import CiString, URL, DisplayText, DateTime 5 | from py_ocpi.modules.commands.v_2_2_1.enums import CommandResponseType, CommandResultType 6 | from py_ocpi.modules.tokens.v_2_2_1.schemas import Token 7 | 8 | 9 | class CancelReservation(BaseModel): 10 | """ 11 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#131-cancelreservation-object 12 | """ 13 | response_url: URL 14 | reservation_id: CiString(36) 15 | 16 | 17 | class CommandResponse(BaseModel): 18 | """ 19 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#132-commandresponse-object 20 | """ 21 | result: CommandResponseType 22 | timeout: int 23 | message: List[DisplayText] = [] 24 | 25 | 26 | class CommandResult(BaseModel): 27 | """ 28 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#133-commandresult-object 29 | """ 30 | result: CommandResultType 31 | message: List[DisplayText] = [] 32 | 33 | 34 | class ReserveNow(BaseModel): 35 | """ 36 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#134-reservenow-object 37 | """ 38 | response_url: URL 39 | token: Token 40 | expiry_date: DateTime 41 | reservation_id: CiString(36) 42 | location_id: CiString(36) 43 | evse_uid: Optional[CiString(36)] 44 | authorization_reference: Optional[CiString(36)] 45 | 46 | 47 | class StartSession(BaseModel): 48 | """ 49 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#135-startsession-object 50 | """ 51 | response_url: URL 52 | token: Token 53 | location_id: CiString(36) 54 | evse_uid: Optional[CiString(36)] 55 | connector_id: Optional[CiString(36)] 56 | authorization_reference: Optional[CiString(36)] 57 | 58 | 59 | class StopSession(BaseModel): 60 | """ 61 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#136-stopsession-object 62 | """ 63 | response_url: URL 64 | session_id: CiString(36) 65 | 66 | 67 | class UnlockConnector(BaseModel): 68 | """ 69 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#137-unlockconnector-object 70 | """ 71 | response_url: URL 72 | location_id: CiString(36) 73 | evse_uid: CiString(36) 74 | connector_id: CiString(36) 75 | -------------------------------------------------------------------------------- /py_ocpi/modules/sessions/v_2_2_1/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | 4 | from py_ocpi.modules.cdrs.v_2_2_1.enums import AuthMethod 5 | from py_ocpi.modules.cdrs.v_2_2_1.schemas import CdrToken, ChargingPeriod 6 | from py_ocpi.modules.sessions.v_2_2_1.enums import ProfileType, SessionStatus 7 | from py_ocpi.core.data_types import CiString, Number, Price, String, DateTime 8 | 9 | 10 | class Session(BaseModel): 11 | """ 12 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#131-session-object 13 | """ 14 | country_code: CiString(2) 15 | party_id: CiString(3) 16 | id: CiString(36) 17 | start_date_time: DateTime 18 | end_date_time: Optional[DateTime] 19 | kwh: Number 20 | cdr_token: CdrToken 21 | auth_method: AuthMethod 22 | authorization_reference: Optional[CiString(36)] 23 | location_id: CiString(36) 24 | evse_uid: CiString(36) 25 | connector_id: CiString(36) 26 | meter_id: Optional[String(255)] 27 | currency: String(3) 28 | charging_periods: List[ChargingPeriod] = [] 29 | total_cost: Optional[Price] 30 | status: SessionStatus 31 | last_updated: DateTime 32 | 33 | 34 | class SessionPartialUpdate(BaseModel): 35 | """ 36 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#131-session-object 37 | """ 38 | country_code: Optional[CiString(2)] 39 | party_id: Optional[CiString(3)] 40 | id: Optional[CiString(36)] 41 | start_date_time: Optional[DateTime] 42 | end_date_time: Optional[DateTime] 43 | kwh: Optional[Number] 44 | cdr_token: Optional[CdrToken] 45 | auth_method: Optional[AuthMethod] 46 | authorization_reference: Optional[CiString(36)] 47 | location_id: Optional[CiString(36)] 48 | evse_uid: Optional[CiString(36)] 49 | connector_id: Optional[CiString(36)] 50 | meter_id: Optional[String(255)] 51 | currency: Optional[String(3)] 52 | charging_periods: Optional[List[ChargingPeriod]] 53 | total_cost: Optional[Price] 54 | status: Optional[SessionStatus] 55 | last_updated: Optional[DateTime] 56 | 57 | 58 | class ChargingPreferences(BaseModel): 59 | """ 60 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#132-chargingpreferences-object 61 | """ 62 | profile_type: ProfileType 63 | departure_time: Optional[DateTime] 64 | energy_need: Optional[Number] 65 | discharge_allowed: Optional[bool] 66 | -------------------------------------------------------------------------------- /py_ocpi/modules/tokens/v_2_2_1/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from pydantic import BaseModel 3 | 4 | from py_ocpi.core.data_types import String, CiString, DisplayText, DateTime 5 | from py_ocpi.modules.tokens.v_2_2_1.enums import AllowedType, TokenType, WhitelistType 6 | from py_ocpi.modules.sessions.v_2_2_1.enums import ProfileType 7 | 8 | 9 | class EnergyContract(BaseModel): 10 | """ 11 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#142-energycontract-class 12 | """ 13 | supplier_name: String(64) 14 | contract_id: Optional[String(64)] 15 | 16 | 17 | class LocationReference(BaseModel): 18 | """ 19 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#143-locationreferences-class 20 | """ 21 | location_id: CiString(36) 22 | evse_uids: List[CiString(36)] = [] 23 | 24 | 25 | class Token(BaseModel): 26 | """ 27 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#132-token-object 28 | """ 29 | country_code: CiString(2) 30 | party_id: CiString(3) 31 | uid: CiString(36) 32 | type: TokenType 33 | contract_id: CiString(36) 34 | visual_number: Optional[String(64)] 35 | issuer: String(64) 36 | group_id: Optional[CiString(36)] 37 | valid: bool 38 | whitelist: WhitelistType 39 | language: Optional[String(2)] 40 | default_profile_type: Optional[ProfileType] 41 | energy_contract: Optional[EnergyContract] 42 | last_updated: DateTime 43 | 44 | 45 | class TokenPartialUpdate(BaseModel): 46 | country_code: Optional[CiString(2)] 47 | party_id: Optional[CiString(3)] 48 | uid: Optional[CiString(36)] 49 | type: Optional[TokenType] 50 | contract_id: Optional[CiString(36)] 51 | visual_number: Optional[String(64)] 52 | issuer: Optional[String(64)] 53 | group_id: Optional[CiString(36)] 54 | valid: Optional[bool] 55 | whitelist: Optional[WhitelistType] 56 | language: Optional[String(2)] 57 | default_profile_type: Optional[ProfileType] 58 | energy_contract: Optional[EnergyContract] 59 | last_updated: Optional[DateTime] 60 | 61 | 62 | class AuthorizationInfo(BaseModel): 63 | """ 64 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#131-authorizationinfo-object 65 | """ 66 | allowed: AllowedType 67 | token: Token 68 | location: Optional[LocationReference] 69 | authorization_reference: Optional[CiString(36)] 70 | info: Optional[DisplayText] 71 | -------------------------------------------------------------------------------- /py_ocpi/modules/tariffs/v_2_2_1/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class DayOfWeek(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#141-dayofweek-enum 7 | """ 8 | monday = 'MONDAY' 9 | tuesday = 'TUESDAY' 10 | wednesday = 'WEDNESDAY' 11 | thursday = 'THURSDAY' 12 | friday = 'FRIDAY' 13 | saturday = 'SATURDAY' 14 | sunday = 'SUNDAY' 15 | 16 | 17 | class ReservationRestrictionType(str, Enum): 18 | """ 19 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#143-reservationrestrictiontype-enum 20 | """ 21 | # Used in TariffElements to describe costs for a reservation. 22 | reservation = 'RESERVATION' 23 | # Used in TariffElements to describe costs for a reservation that expires 24 | # (i.e. driver does not start a charging session before expiry_date of the reservation). 25 | reservation_expires = 'RESERVATION_EXPIRES' 26 | 27 | 28 | class TariffDimensionType(str, Enum): 29 | """ 30 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#145-tariffdimensiontype-enum 31 | """ 32 | # Defined in kWh, step_size multiplier: 1 Wh 33 | energy = 'ENERGY' 34 | # Flat fee without unit for step_size 35 | flat = 'FLAT' 36 | # Time not charging: defined in hours, step_size multiplier: 1 second 37 | parking_time = 'PARKING_TIME' 38 | # Time charging: defined in hours, step_size multiplier: 1 second 39 | # Can also be used in combination with a RESERVATION restriction to describe the price of the reservation time. 40 | time = 'TIME' 41 | 42 | 43 | class TariffType(str, Enum): 44 | """ 45 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#147-tarifftype-enum 46 | """ 47 | # Used to describe that a Tariff is valid when ad-hoc payment is used at the Charge Point 48 | # (for example: Debit or Credit card payment terminal). 49 | ad_hoc_payment = 'AD_HOC_PAYMENT' 50 | # Used to describe that a Tariff is valid when Charging Preference: CHEAP is set for the session. 51 | profile_cheap = 'PROFILE_CHEAP' 52 | # Used to describe that a Tariff is valid when Charging Preference: FAST is set for the session. 53 | profile_fast = 'PROFILE_FAST' 54 | # Used to describe that a Tariff is valid when Charging Preference: GREEN is set for the session. 55 | profile_green = 'PROFILE_GREEN' 56 | # Used to describe that a Tariff is valid when using an RFID, without any Charging Preference, 57 | # or when Charging Preference: REGULAR is set for the session. 58 | regular = 'REGULAR' 59 | -------------------------------------------------------------------------------- /py_ocpi/modules/commands/v_2_2_1/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CommandResponseType(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#141-commandresponsetype-enum 7 | """ 8 | # The requested command is not supported by this CPO, Charge Point, EVSE etc. 9 | not_supported = 'NOT_SUPPORTED' 10 | # Command request rejected by the CPO. (Session might not be from a customer of the eMSP that send this request) 11 | rejected = 'REJECTED' 12 | # Command request accepted by the CPO. 13 | accepted = 'ACCEPTED' 14 | # The Session in the requested command is not known by this CPO 15 | unknown_session = 'UNKNOWN_SESSION' 16 | 17 | 18 | class CommandResultType(str, Enum): 19 | """ 20 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#142-commandresulttype-enum 21 | """ 22 | # Command request accepted by the Charge Point. 23 | accepted = 'ACCEPTED' 24 | # The Reservation has been canceled by the CPO. 25 | canceled_reservation = 'CANCELED_RESERVATION' 26 | # EVSE is currently occupied, another session is ongoing. Cannot start a new session 27 | evse_occupied = 'EVSE_OCCUPIED' 28 | # EVSE is currently inoperative or faulted. 29 | evse_inoperative = 'EVSE_INOPERATIVE' 30 | # Execution of the command failed at the Charge Point. 31 | failed = 'FAILED' 32 | # The requested command is not supported by this Charge Point, EVSE etc. 33 | not_supported = 'NOT_SUPPORTED' 34 | # Command request rejected by the Charge Point. 35 | rejected = 'REJECTED' 36 | # Command request timeout, no response received from the Charge Point in a reasonable time. 37 | timeout = 'TIMEOUT' 38 | # The Reservation in the requested command is not known by this Charge Point. 39 | unknown_reservation = 'UNKNOWN_RESERVATION' 40 | 41 | 42 | class CommandType(str, Enum): 43 | """ 44 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#143-commandtype-enum 45 | """ 46 | # Request the Charge Point to cancel a specific reservation. 47 | cancel_reservation = 'CANCEL_RESERVATION' 48 | # Request the Charge Point to reserve a (specific) EVSE for a Token for a certain time, starting now. 49 | reserve_now = 'RESERVE_NOW' 50 | # Request the Charge Point to start a transaction on the given EVSE/Connector. 51 | start_session = 'START_SESSION' 52 | # Request the Charge Point to stop an ongoing session. 53 | stop_session = 'STOP_SESSION' 54 | # Request the Charge Point to unlock the connector (if applicable). 55 | # This functionality is for help desk operators only! 56 | unlock_connector = 'UNLOCK_CONNECTOR' 57 | -------------------------------------------------------------------------------- /py_ocpi/modules/sessions/v_2_2_1/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ChargingPreferencesResponse(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#141-chargingpreferencesresponse-enum 7 | """ 8 | # Charging Preferences accepted, EVSE will try to accomplish them, 9 | # although this is no guarantee that they will be fulfilled. 10 | accepted = 'ACCEPTED' 11 | # CPO requires departure_time to be able to perform Charging Preference based Smart Charging. 12 | departure_required = 'DEPARTURE_REQUIRED' 13 | # CPO requires energy_need to be able to perform Charging Preference based Smart Charging. 14 | energy_need_required = 'ENERGY_NEED_REQUIRED' 15 | # Charging Preferences contain a demand that the EVSE knows it cannot fulfill. 16 | not_possible = 'NOT_POSSIBLE' 17 | # profile_type contains a value that is not supported by the EVSE. 18 | profile_type_not_supported = 'PROFILE_TYPE_NOT_SUPPORTED' 19 | 20 | 21 | class ProfileType(str, Enum): 22 | """ 23 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#142-profiletype-enum 24 | """ 25 | # Driver wants to use the cheapest charging profile possible. 26 | cheap = 'CHEAP' 27 | # Driver wants his EV charged as quickly as possible and is willing to pay a premium for this, if needed. 28 | fast = 'FAST' 29 | # Driver wants his EV charged with as much regenerative (green) energy as possible. 30 | green = 'GREEN' 31 | # Driver does not have special preferences. 32 | regular = 'REGULAR' 33 | 34 | 35 | class SessionStatus(str, Enum): 36 | """ 37 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#143-sessionstatus-enum 38 | """ 39 | # The session has been accepted and is active. All pre-conditions were met: Communication between EV and EVSE 40 | # (for example: cable plugged in correctly), EV or driver is authorized. EV is being charged, or can be charged. 41 | # Energy is, or is not, being transfered. 42 | active = 'ACTIVE' 43 | # The session has been finished successfully. 44 | # No more modifications will be made to the Session object using this state. 45 | completed = 'COMPLETED' 46 | # The Session object using this state is declared invalid and will not be billed. 47 | invalid = 'INVALID' 48 | # The session is pending, it has not yet started. Not all pre-conditions are met. 49 | # This is the initial state. The session might never become an active session. 50 | pending = 'PENDING' 51 | # The session is started due to a reservation, charging has not yet started. 52 | # The session might never become an active session. 53 | reservation = 'RESERVATION' 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | .DS_Store 10 | # Distribution / packaging 11 | .Python 12 | .idea/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | 142 | # Text Editor 143 | .vscode 144 | -------------------------------------------------------------------------------- /py_ocpi/modules/cdrs/v_2_2_1/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class AuthMethod(str, Enum): 5 | """ 6 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#141-authmethod-enum 7 | """ 8 | # Authentication request has been sent to the eMSP. 9 | auth_request = 'AUTH_REQUEST' 10 | # Command like StartSession or ReserveNow used to start the Session, 11 | # the Token provided in the Command was used as authorization. 12 | command = 'COMMAND' 13 | # Whitelist used for authentication, no request to the eMSP has been performed. 14 | whitelist = 'WHITELIST' 15 | 16 | 17 | class CdrDimensionType(str, Enum): 18 | """ 19 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#143-cdrdimensiontype-enum 20 | """ 21 | # Average charging current during this ChargingPeriod: defined in A (Ampere). 22 | # When negative, the current is flowing from the EV to the grid. 23 | current = 'CURRENT' 24 | # Total amount of energy (dis-)charged during this ChargingPeriod: defined in kWh. 25 | # When negative, more energy was feed into the grid then charged into the EV. 26 | # Default step_size is 1. 27 | energy = 'ENERGY' 28 | # Total amount of energy feed back into the grid: defined in kWh. 29 | energy_export = 'ENERGY_EXPORT' 30 | # Total amount of energy charged, defined in kWh. 31 | energy_import = 'ENERGY_IMPORT' 32 | # Sum of the maximum current over all phases, reached during this ChargingPeriod: defined in A (Ampere). 33 | max_current = 'MAX_CURRENT' 34 | # Sum of the minimum current over all phases, reached during this ChargingPeriod, when negative, 35 | # current has flowed from the EV to the grid. Defined in A (Ampere). 36 | min_current = 'MIN_CURRENT' 37 | # Maximum power reached during this ChargingPeriod: defined in kW (Kilowatt). 38 | max_power = 'MAX_POWER' 39 | # Minimum power reached during this ChargingPeriod: defined in kW (Kilowatt), 40 | # when negative, the power has flowed from the EV to the grid. 41 | min_power = 'MIN_POWER' 42 | # Time during this ChargingPeriod not charging: defined in hours, default step_size multiplier is 1 second. 43 | parking_time = 'PARKING_TIME' 44 | # Average power during this ChargingPeriod: defined in kW (Kilowatt). 45 | # When negative, the power is flowing from the EV to the grid. 46 | power = 'POWER' 47 | # Time during this ChargingPeriod Charge Point has been reserved and not yet been in use for this customer: 48 | # defined in hours, default step_size multiplier is 1 second. 49 | reservation_time = 'RESERVATION_TIME' 50 | # Current state of charge of the EV, in percentage, values allowed: 0 to 100. See note below. 51 | state_of_change = 'STATE_OF_CHARGE' 52 | # Time charging during this ChargingPeriod: defined in hours, default step_size multiplier is 1 second. 53 | time = 'TIME' 54 | -------------------------------------------------------------------------------- /py_ocpi/modules/tariffs/v_2_2_1/api/emsp.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Request 2 | 3 | from py_ocpi.modules.tariffs.v_2_2_1.schemas import Tariff 4 | from py_ocpi.modules.versions.enums import VersionNumber 5 | from py_ocpi.core.utils import get_auth_token 6 | from py_ocpi.core import status 7 | from py_ocpi.core.schemas import OCPIResponse 8 | from py_ocpi.core.adapter import Adapter 9 | from py_ocpi.core.crud import Crud 10 | from py_ocpi.core.data_types import CiString 11 | from py_ocpi.core.enums import ModuleID, RoleEnum 12 | from py_ocpi.core.dependencies import get_crud, get_adapter 13 | 14 | router = APIRouter( 15 | prefix='/tariffs', 16 | ) 17 | 18 | 19 | @router.get("/{country_code}/{party_id}/{tariff_id}", response_model=OCPIResponse) 20 | async def get_tariff(request: Request, country_code: CiString(2), party_id: CiString(3), tariff_id: CiString(36), 21 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 22 | auth_token = get_auth_token(request) 23 | 24 | data = await crud.get(ModuleID.tariffs, RoleEnum.emsp, tariff_id, auth_token=auth_token, 25 | country_code=country_code, party_id=party_id, version=VersionNumber.v_2_2_1) 26 | return OCPIResponse( 27 | data=[adapter.tariff_adapter(data, VersionNumber.v_2_2_1).dict()], 28 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 29 | ) 30 | 31 | 32 | @router.put("/{country_code}/{party_id}/{tariff_id}", response_model=OCPIResponse) 33 | async def add_or_update_tariff(request: Request, country_code: CiString(2), party_id: CiString(3), 34 | tariff_id: CiString(36), tariff: Tariff, 35 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 36 | auth_token = get_auth_token(request) 37 | 38 | data = await crud.get(ModuleID.tariffs, RoleEnum.emsp, tariff_id, auth_token=auth_token, 39 | country_code=country_code, party_id=party_id, version=VersionNumber.v_2_2_1) 40 | if data: 41 | data = await crud.update(ModuleID.tariffs, RoleEnum.emsp, tariff.dict(), tariff_id, 42 | auth_token=auth_token, country_code=country_code, 43 | party_id=party_id, version=VersionNumber.v_2_2_1) 44 | else: 45 | data = await crud.create(ModuleID.tariffs, RoleEnum.emsp, tariff.dict(), 46 | auth_token=auth_token, country_code=country_code, 47 | party_id=party_id, version=VersionNumber.v_2_2_1) 48 | 49 | return OCPIResponse( 50 | data=[adapter.tariff_adapter(data).dict()], 51 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 52 | ) 53 | 54 | 55 | @router.delete("/{country_code}/{party_id}/{tariff_id}", response_model=OCPIResponse) 56 | async def delete_tariff(request: Request, country_code: CiString(2), party_id: CiString(3), tariff_id: CiString(36), 57 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 58 | auth_token = get_auth_token(request) 59 | 60 | await crud.delete(ModuleID.tariffs, RoleEnum.emsp, tariff_id, 61 | auth_token=auth_token, country_code=country_code, 62 | party_id=party_id, version=VersionNumber.v_2_2_1) 63 | 64 | return OCPIResponse( 65 | data=[], 66 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 67 | ) 68 | -------------------------------------------------------------------------------- /py_ocpi/modules/tokens/v_2_2_1/api/emsp.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Response, Request, status as http_status 2 | 3 | from py_ocpi.modules.tokens.v_2_2_1.enums import TokenType 4 | from py_ocpi.modules.tokens.v_2_2_1.schemas import LocationReference, AuthorizationInfo 5 | from py_ocpi.modules.versions.enums import VersionNumber 6 | from py_ocpi.core.utils import get_list, get_auth_token 7 | from py_ocpi.core import status 8 | from py_ocpi.core.schemas import OCPIResponse 9 | from py_ocpi.core.adapter import Adapter 10 | from py_ocpi.core.crud import Crud 11 | from py_ocpi.core.exceptions import NotFoundOCPIError 12 | from py_ocpi.core.data_types import CiString 13 | from py_ocpi.core.enums import ModuleID, RoleEnum, Action 14 | from py_ocpi.core.dependencies import get_crud, get_adapter, pagination_filters 15 | 16 | router = APIRouter( 17 | prefix='/tokens', 18 | ) 19 | 20 | 21 | @router.get("/", response_model=OCPIResponse) 22 | async def get_tokens(request: Request, 23 | response: Response, 24 | crud: Crud = Depends(get_crud), 25 | adapter: Adapter = Depends(get_adapter), 26 | filters: dict = Depends(pagination_filters)): 27 | auth_token = get_auth_token(request) 28 | 29 | data_list = await get_list(response, filters, ModuleID.tokens, RoleEnum.emsp, 30 | VersionNumber.v_2_2_1, crud, auth_token=auth_token) 31 | 32 | tokens = [] 33 | for data in data_list: 34 | tokens.append(adapter.token_adapter(data).dict()) 35 | 36 | return OCPIResponse( 37 | data=tokens, 38 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 39 | ) 40 | 41 | 42 | @router.post("/{token_uid}/authorize", response_model=OCPIResponse) 43 | async def authorize_token(request: Request, response: Response, 44 | token_uid: CiString(36), token_type: TokenType = TokenType.rfid, 45 | location_reference: LocationReference = None, 46 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 47 | auth_token = get_auth_token(request) 48 | try: 49 | # check if token exists 50 | await crud.get(ModuleID.tokens, RoleEnum.emsp, token_uid, 51 | auth_token=auth_token, token_type=token_type, 52 | version=VersionNumber.v_2_2_1) 53 | 54 | location_reference = location_reference.dict() if location_reference else None 55 | data = { 56 | 'token_uid': token_uid, 57 | 'token_type': token_type, 58 | 'location_reference': location_reference 59 | } 60 | authroization_result = await crud.do(ModuleID.tokens, RoleEnum.emsp, Action.authorize_token, data=data, 61 | auth_token=auth_token) 62 | 63 | # when the token information is not enough 64 | if not authroization_result: 65 | return OCPIResponse( 66 | data=[], 67 | **status.OCPI_2002_NOT_ENOUGH_INFORMATION, 68 | ) 69 | 70 | return OCPIResponse( 71 | data=[adapter.authorization_adapter(authroization_result).dict()], 72 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 73 | ) 74 | 75 | # when the token is not found 76 | except NotFoundOCPIError: 77 | response.status_code = http_status.HTTP_404_NOT_FOUND 78 | return OCPIResponse( 79 | data=[], 80 | **status.OCPI_2004_UNKNOWN_TOKEN, 81 | ) 82 | -------------------------------------------------------------------------------- /tests/test_modules/test_tariffs.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi.main import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.core.config import settings 8 | from py_ocpi.modules.tariffs.v_2_2_1.schemas import Tariff 9 | from py_ocpi.modules.versions.enums import VersionNumber 10 | 11 | TARIFFS = [{ 12 | 'country_code': 'MY', 13 | 'party_id': 'JOM', 14 | 'id': str(uuid4()), 15 | 'currency': 'MYR', 16 | 'type': 'REGULAR', 17 | 'elements': [ 18 | { 19 | 'price_components': [ 20 | { 21 | 'type': 'ENERGY', 22 | 'price': 1.50, 23 | 'step_size': 2 24 | }, 25 | ] 26 | }, 27 | ], 28 | 'last_updated': '2022-01-02 00:00:00+00:00' 29 | }, 30 | ] 31 | 32 | 33 | class Crud: 34 | @classmethod 35 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs): 36 | return TARIFFS[0] 37 | 38 | @classmethod 39 | async def update(cls, module: enums.ModuleID, role: enums.RoleEnum, data: dict, id, *args, **kwargs): 40 | return data 41 | 42 | @classmethod 43 | async def create(cls, module: enums.ModuleID, role: enums.RoleEnum, data: dict, *args, **kwargs): 44 | return data 45 | 46 | @classmethod 47 | async def delete(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs): 48 | ... 49 | 50 | @classmethod 51 | async def list(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> list: 52 | return TARIFFS, 1, True 53 | 54 | 55 | class Adapter: 56 | @classmethod 57 | def tariff_adapter(cls, data, version: VersionNumber = VersionNumber.latest) -> Tariff: 58 | return Tariff(**data) 59 | 60 | 61 | def test_cpo_get_tariffs_v_2_2_1(): 62 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 63 | 64 | client = TestClient(app) 65 | response = client.get('/ocpi/cpo/2.2.1/tariffs') 66 | 67 | assert response.status_code == 200 68 | assert len(response.json()['data']) == 1 69 | assert response.json()['data'][0]['id'] == TARIFFS[0]["id"] 70 | 71 | 72 | def test_emsp_get_tariff_v_2_2_1(): 73 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 74 | 75 | client = TestClient(app) 76 | response = client.get(f'/ocpi/emsp/2.2.1/tariffs/{settings.COUNTRY_CODE}/{settings.PARTY_ID}' 77 | f'/{TARIFFS[0]["id"]}') 78 | 79 | assert response.status_code == 200 80 | assert response.json()['data'][0]['id'] == TARIFFS[0]["id"] 81 | 82 | 83 | def test_emsp_add_tariff_v_2_2_1(): 84 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 85 | 86 | client = TestClient(app) 87 | response = client.put(f'/ocpi/emsp/2.2.1/tariffs/{settings.COUNTRY_CODE}/{settings.PARTY_ID}' 88 | f'/{TARIFFS[0]["id"]}', json=TARIFFS[0]) 89 | 90 | assert response.status_code == 200 91 | assert response.json()['data'][0]['id'] == TARIFFS[0]["id"] 92 | 93 | 94 | def test_emsp_delete_tariff_v_2_2_1(): 95 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 96 | 97 | client = TestClient(app) 98 | response = client.delete(f'/ocpi/emsp/2.2.1/tariffs/{settings.COUNTRY_CODE}/{settings.PARTY_ID}' 99 | f'/{TARIFFS[0]["id"]}') 100 | 101 | assert response.status_code == 200 102 | -------------------------------------------------------------------------------- /py_ocpi/modules/locations/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Response, Request 2 | 3 | from py_ocpi.modules.versions.enums import VersionNumber 4 | from py_ocpi.core.utils import get_list, get_auth_token 5 | from py_ocpi.core import status 6 | from py_ocpi.core.schemas import OCPIResponse 7 | from py_ocpi.core.adapter import Adapter 8 | from py_ocpi.core.crud import Crud 9 | from py_ocpi.core.data_types import CiString 10 | from py_ocpi.core.enums import ModuleID, RoleEnum 11 | from py_ocpi.core.dependencies import get_crud, get_adapter, pagination_filters 12 | 13 | router = APIRouter( 14 | prefix='/locations', 15 | ) 16 | 17 | 18 | @router.get("/", response_model=OCPIResponse) 19 | async def get_locations(request: Request, 20 | response: Response, 21 | crud: Crud = Depends(get_crud), 22 | adapter: Adapter = Depends(get_adapter), 23 | filters: dict = Depends(pagination_filters)): 24 | auth_token = get_auth_token(request) 25 | 26 | data_list = await get_list(response, filters, ModuleID.locations, RoleEnum.cpo, 27 | VersionNumber.v_2_2_1, crud, auth_token=auth_token) 28 | 29 | locations = [] 30 | for data in data_list: 31 | locations.append(adapter.location_adapter(data).dict()) 32 | return OCPIResponse( 33 | data=locations, 34 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 35 | ) 36 | 37 | 38 | @router.get("/{location_id}", response_model=OCPIResponse) 39 | async def get_location(request: Request, location_id: CiString(36), 40 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 41 | auth_token = get_auth_token(request) 42 | 43 | data = await crud.get(ModuleID.locations, RoleEnum.cpo, location_id, auth_token=auth_token, 44 | version=VersionNumber.v_2_2_1) 45 | return OCPIResponse( 46 | data=[adapter.location_adapter(data).dict()], 47 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 48 | ) 49 | 50 | 51 | @router.get("/{location_id}/{evse_uid}", response_model=OCPIResponse) 52 | async def get_evse(request: Request, location_id: CiString(36), evse_uid: CiString(48), 53 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 54 | auth_token = get_auth_token(request) 55 | 56 | data = await crud.get(ModuleID.locations, RoleEnum.cpo, location_id, auth_token=auth_token, 57 | version=VersionNumber.v_2_2_1) 58 | location = adapter.location_adapter(data) 59 | for evse in location.evses: 60 | if evse.uid == evse_uid: 61 | return OCPIResponse( 62 | data=[evse.dict()], 63 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 64 | ) 65 | 66 | 67 | @router.get("/{location_id}/{evse_uid}/{connector_id}", response_model=OCPIResponse) 68 | async def get_connector(request: Request, location_id: CiString(36), evse_uid: CiString(48), connector_id: CiString(36), 69 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 70 | auth_token = get_auth_token(request) 71 | 72 | data = await crud.get(ModuleID.locations, RoleEnum.cpo, location_id, auth_token=auth_token, 73 | version=VersionNumber.v_2_2_1) 74 | location = adapter.location_adapter(data) 75 | for evse in location.evses: 76 | if evse.uid == evse_uid: 77 | for connector in evse.connectors: 78 | if connector.id == connector_id: 79 | return OCPIResponse( 80 | data=[connector.dict()], 81 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 82 | ) 83 | -------------------------------------------------------------------------------- /py_ocpi/modules/cdrs/v_2_2_1/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel 4 | from py_ocpi.modules.cdrs.v_2_2_1.enums import AuthMethod, CdrDimensionType 5 | 6 | from py_ocpi.core.data_types import CiString, Number, Price, String, DateTime 7 | from py_ocpi.modules.tokens.v_2_2_1.enums import TokenType 8 | from py_ocpi.modules.tariffs.v_2_2_1.schemas import Tariff 9 | from py_ocpi.modules.locations.v_2_2_1.schemas import GeoLocation 10 | from py_ocpi.modules.locations.v_2_2_1.enums import ConnectorFormat, ConnectorType, PowerType 11 | 12 | 13 | class SignedValue(BaseModel): 14 | """ 15 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#148-signedvalue-class 16 | """ 17 | nature: CiString(32) 18 | plain_data: String(512) 19 | singed_data: String(5000) 20 | 21 | 22 | class SignedData(BaseModel): 23 | """ 24 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#147-signeddata-class 25 | """ 26 | encoding_method: CiString(36) 27 | encoding_method_version: Optional[int] 28 | public_key: Optional[String(512)] 29 | signed_value: List[SignedValue] 30 | url: Optional[String(512)] 31 | 32 | 33 | class CdrDimension(BaseModel): 34 | """ 35 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#142-cdrdimension-class 36 | """ 37 | type: CdrDimensionType 38 | volume: Number 39 | 40 | 41 | class ChargingPeriod(BaseModel): 42 | """ 43 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#146-chargingperiod-class 44 | """ 45 | start_date_time: DateTime 46 | dimensions: List[CdrDimension] 47 | tariff_id: Optional[CiString(36)] 48 | 49 | 50 | class CdrToken(BaseModel): 51 | """ 52 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#145-cdrtoken-class 53 | """ 54 | country_code: CiString(2) 55 | party_id: CiString(3) 56 | uid: CiString(36) 57 | type: TokenType 58 | contract_id: CiString(36) 59 | 60 | 61 | class CdrLocation(BaseModel): 62 | """ 63 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#144-cdrlocation-class 64 | """ 65 | id: CiString(36) 66 | name: Optional[String(255)] 67 | address: String(45) 68 | city: String(45) 69 | postal_code: Optional[String(10)] 70 | state: Optional[String(20)] 71 | country: String(3) 72 | coordinates: GeoLocation 73 | evse_id: CiString(48) 74 | connector_id: CiString(36) 75 | connector_standard: ConnectorType 76 | connector_format: ConnectorFormat 77 | connector_power_type: PowerType 78 | 79 | 80 | class Cdr(BaseModel): 81 | """ 82 | https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#131-cdr-object 83 | """ 84 | country_code: CiString(2) 85 | party_id: CiString(3) 86 | id: CiString(39) 87 | start_date_time: DateTime 88 | end_date_time: DateTime 89 | session_id: Optional[CiString(36)] 90 | cdr_token: CdrToken 91 | auth_method: AuthMethod 92 | authorization_reference: Optional[CiString(36)] 93 | cdr_location: CdrLocation 94 | meter_id: Optional[String(255)] 95 | currency: String(3) 96 | tariffs: List[Tariff] = [] 97 | charging_periods: List[ChargingPeriod] 98 | signed_data: Optional[SignedData] 99 | total_cost: Price 100 | total_fixed_cost: Optional[Price] 101 | total_energy: Number 102 | total_energy_cost: Optional[Price] 103 | total_time: Number 104 | total_time_cost: Optional[Price] 105 | total_parking_time: Optional[Number] 106 | total_parking_cost: Optional[Price] 107 | total_reservation_cost: Optional[Price] 108 | remark: Optional[String(255)] 109 | invoice_reference_id: Optional[CiString(36)] 110 | credit: Optional[bool] 111 | credit_reference_id: Optional[CiString(39)] 112 | home_charging_compensation: Optional[bool] 113 | last_updated: DateTime 114 | -------------------------------------------------------------------------------- /py_ocpi/modules/sessions/v_2_2_1/api/emsp.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Request 2 | 3 | from py_ocpi.modules.sessions.v_2_2_1.schemas import SessionPartialUpdate, Session 4 | from py_ocpi.modules.versions.enums import VersionNumber 5 | from py_ocpi.core.utils import get_auth_token, partially_update_attributes 6 | from py_ocpi.core import status 7 | from py_ocpi.core.schemas import OCPIResponse 8 | from py_ocpi.core.adapter import Adapter 9 | from py_ocpi.core.crud import Crud 10 | from py_ocpi.core.data_types import CiString 11 | from py_ocpi.core.enums import ModuleID, RoleEnum 12 | from py_ocpi.core.dependencies import get_crud, get_adapter 13 | 14 | router = APIRouter( 15 | prefix='/sessions', 16 | ) 17 | 18 | 19 | @router.get("/{country_code}/{party_id}/{session_id}", response_model=OCPIResponse) 20 | async def get_session(request: Request, country_code: CiString(2), party_id: CiString(3), session_id: CiString(36), 21 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 22 | auth_token = get_auth_token(request) 23 | 24 | data = await crud.get(ModuleID.sessions, RoleEnum.emsp, session_id, auth_token=auth_token, 25 | country_code=country_code, party_id=party_id, version=VersionNumber.v_2_2_1) 26 | return OCPIResponse( 27 | data=[adapter.session_adapter(data, VersionNumber.v_2_2_1).dict()], 28 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 29 | ) 30 | 31 | 32 | @router.put("/{country_code}/{party_id}/{session_id}", response_model=OCPIResponse) 33 | async def add_or_update_session(request: Request, country_code: CiString(2), party_id: CiString(3), 34 | session_id: CiString(36), session: Session, 35 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 36 | auth_token = get_auth_token(request) 37 | 38 | data = await crud.get(ModuleID.sessions, RoleEnum.emsp, session_id, auth_token=auth_token, 39 | country_code=country_code, party_id=party_id, version=VersionNumber.v_2_2_1) 40 | if data: 41 | data = await crud.update(ModuleID.sessions, RoleEnum.emsp, session.dict(), session_id, 42 | auth_token=auth_token, country_code=country_code, 43 | party_id=party_id, version=VersionNumber.v_2_2_1) 44 | else: 45 | data = await crud.create(ModuleID.sessions, RoleEnum.emsp, session.dict(), 46 | auth_token=auth_token, country_code=country_code, 47 | party_id=party_id, version=VersionNumber.v_2_2_1) 48 | 49 | return OCPIResponse( 50 | data=[adapter.session_adapter(data).dict()], 51 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 52 | ) 53 | 54 | 55 | @router.patch("/{country_code}/{party_id}/{session_id}", response_model=OCPIResponse) 56 | async def partial_update_session(request: Request, country_code: CiString(2), party_id: CiString(3), 57 | session_id: CiString(36), session: SessionPartialUpdate, 58 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 59 | auth_token = get_auth_token(request) 60 | 61 | old_data = await crud.get(ModuleID.sessions, RoleEnum.emsp, session_id, auth_token=auth_token, 62 | country_code=country_code, party_id=party_id, version=VersionNumber.v_2_2_1) 63 | old_session = adapter.session_adapter(old_data) 64 | 65 | new_session = old_session 66 | partially_update_attributes(new_session, session.dict(exclude_defaults=True, exclude_unset=True)) 67 | 68 | data = await crud.update(ModuleID.sessions, RoleEnum.emsp, new_session.dict(), session_id, 69 | auth_token=auth_token, country_code=country_code, 70 | party_id=party_id, version=VersionNumber.v_2_2_1) 71 | 72 | return OCPIResponse( 73 | data=[adapter.session_adapter(data).dict()], 74 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 75 | ) 76 | -------------------------------------------------------------------------------- /docs/tutorials/modules/locations.md: -------------------------------------------------------------------------------- 1 | # Locations 2 | 3 | Every CRUD method call from this module has _module\_id_ = locations, _auth\_token_ = {token used in HTTP request header}, version = {OCPI version of the module} 4 | 5 | ## CPO 6 | Every CRUD method call from this module has _role_ = CPO 7 | 8 | - **GET** `/` 9 | 10 | crud.list is called with _filters_ argument containing _date\_from_, _date\_to_, _offset_ and _limit_ keys 11 | 12 | - **GET** `/{location_id}` 13 | 14 | crud.get is called with _id_ = _location\_id_ 15 | 16 | - **GET** `/{location_id}/{evse_uid}` 17 | 18 | crud.get is called with _id_ = _location\_id_ 19 | 20 | - **GET** `/{location_id}/{evse_uid}/{connector_id}` 21 | 22 | crud.get is called with _id_ = _location\_id_ 23 | 24 | 25 | ## EMSP 26 | Every CRUD method call from this module has _role_ = EMSP 27 | 28 | - **GET** `/{country_code}/{party_id}/{location_id}` 29 | 30 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 31 | 32 | - **GET** `/{country_code}/{party_id}/{location_id}/{evse_uid}` 33 | 34 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 35 | 36 | - **GET** `/{country_code}/{party_id}/{location_id}/{evse_uid}/{connector_id}` 37 | 38 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 39 | 40 | - **PUT** `/{country_code}/{party_id}/{location_id}` 41 | 42 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 43 | 44 | if object exists crud.update is called with _id_ = _location\_id_, data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 45 | 46 | if object doesn't exist crud.create is called with data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 47 | 48 | - **PUT** `/{country_code}/{party_id}/{location_id}/{evse_uid}` 49 | 50 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 51 | 52 | if object exists crud.update is called with _id_ = _location\_id_, data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 53 | 54 | if object doesn't exist crud.create is called with data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 55 | 56 | - **PUT** `/{country_code}/{party_id}/{location_id}/{evse_uid}/{connector_id}` 57 | 58 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 59 | 60 | if object exists crud.update is called with _id_ = _location\_id_, data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 61 | 62 | if object doesn't exist crud.create is called with data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 63 | 64 | - **PATCH** `/{country_code}/{party_id}/{location_id}` 65 | 66 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 67 | 68 | crud.update is called with _id_ = _location\_id_, data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 69 | 70 | - **PATCH** `/{country_code}/{party_id}/{location_id}/{evse_uid}` 71 | 72 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 73 | 74 | crud.update is called with _id_ = _location\_id_, data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 75 | 76 | - **PATCH** `/{country_code}/{party_id}/{location_id}/{evse_uid}/{connector_id}` 77 | 78 | crud.get is called with _id_ = _location\_id_, _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 79 | 80 | crud.update is called with _id_ = _location\_id_, data = dict (with standard OCPI Location schema), _country\_code_ = _country\_code_ and _party\_id_ = _party\_id_ 81 | -------------------------------------------------------------------------------- /tests/test_modules/test_cdrs.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.modules.locations.v_2_2_1.schemas import ConnectorType, ConnectorFormat, PowerType 8 | from py_ocpi.modules.cdrs.v_2_2_1.schemas import TokenType, Cdr 9 | from py_ocpi.modules.cdrs.v_2_2_1.enums import AuthMethod, CdrDimensionType 10 | from py_ocpi.modules.versions.enums import VersionNumber 11 | 12 | CDRS = [ 13 | { 14 | 'country_code': 'us', 15 | 'party_id': 'AAA', 16 | 'id': str(uuid4()), 17 | 'start_date_time': '2022-01-02 00:00:00+00:00', 18 | 'end_date_time': '2022-01-02 00:05:00+00:00', 19 | 'cdr_token': { 20 | 'country_code': 'us', 21 | 'party_id': 'AAA', 22 | 'uid': str(uuid4()), 23 | 'type': TokenType.rfid, 24 | 'contract_id': str(uuid4()) 25 | }, 26 | 'auth_method': AuthMethod.auth_request, 27 | 'cdr_location': { 28 | 'id': str(uuid4()), 29 | 'name': 'name', 30 | 'address': 'address', 31 | 'city': 'city', 32 | 'postal_code': '111111', 33 | 'state': 'state', 34 | 'country': 'USA', 35 | 'coordinates': { 36 | 'latitude': 'latitude', 37 | 'longitude': 'longitude', 38 | }, 39 | 'evse_id': str(uuid4()), 40 | 'connector_id': str(uuid4()), 41 | 'connector_standard': ConnectorType.tesla_r, 42 | 'connector_format': ConnectorFormat.cable, 43 | 'connector_power_type': PowerType.dc 44 | }, 45 | 'currency': 'MYR', 46 | 'charging_periods': [ 47 | { 48 | 'start_date_time': '2022-01-02 00:00:00+00:00', 49 | 'dimensions': [ 50 | { 51 | 'type': CdrDimensionType.power, 52 | 'volume': 10 53 | } 54 | ] 55 | } 56 | ], 57 | 'total_cost': { 58 | 'excl_vat': 10.0000, 59 | 'incl_vat': 10.2500 60 | }, 61 | 'total_energy': 50, 62 | 'total_time': 500, 63 | 'last_updated': '2022-01-02 00:00:00+00:00' 64 | } 65 | ] 66 | 67 | 68 | class Crud: 69 | 70 | @classmethod 71 | async def list(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> list: 72 | return CDRS, 1, True 73 | 74 | @classmethod 75 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs): 76 | return CDRS[0] 77 | 78 | @classmethod 79 | async def create(cls, module: enums.ModuleID, role: enums.RoleEnum, data: dict, *args, **kwargs): 80 | return data 81 | 82 | 83 | class Adapter: 84 | @classmethod 85 | def cdr_adapter(cls, data, version: VersionNumber = VersionNumber.latest) -> Cdr: 86 | return Cdr(**data) 87 | 88 | 89 | def test_cpo_get_cdrs_v_2_2_1(): 90 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 91 | 92 | client = TestClient(app) 93 | response = client.get('/ocpi/cpo/2.2.1/cdrs') 94 | 95 | assert response.status_code == 200 96 | assert len(response.json()['data']) == 1 97 | assert response.json()['data'][0]['id'] == CDRS[0]["id"] 98 | 99 | 100 | def test_emsp_get_cdr_v_2_2_1(): 101 | 102 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 103 | 104 | client = TestClient(app) 105 | response = client.get(f'/ocpi/emsp/2.2.1/cdrs/{CDRS[0]["id"]}') 106 | 107 | assert response.status_code == 200 108 | assert response.json()['data'][0]['id'] == CDRS[0]["id"] 109 | 110 | 111 | def test_emsp_add_cdr_v_2_2_1(): 112 | 113 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 114 | 115 | data = CDRS[0] 116 | 117 | client = TestClient(app) 118 | response = client.post('/ocpi/emsp/2.2.1/cdrs/', json=data) 119 | 120 | assert response.status_code == 200 121 | assert response.json()['data'][0]['id'] == CDRS[0]["id"] 122 | assert response.headers['Location'] is not None 123 | -------------------------------------------------------------------------------- /py_ocpi/modules/tokens/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Depends 2 | 3 | from py_ocpi.core import status 4 | from py_ocpi.core.data_types import CiString 5 | from py_ocpi.core.enums import ModuleID, RoleEnum 6 | from py_ocpi.core.schemas import OCPIResponse 7 | from py_ocpi.core.adapter import Adapter 8 | from py_ocpi.core.crud import Crud 9 | from py_ocpi.core.utils import get_auth_token, partially_update_attributes 10 | from py_ocpi.core.dependencies import get_crud, get_adapter 11 | from py_ocpi.modules.versions.enums import VersionNumber 12 | from py_ocpi.modules.tokens.v_2_2_1.enums import TokenType 13 | from py_ocpi.modules.tokens.v_2_2_1.schemas import Token, TokenPartialUpdate 14 | 15 | router = APIRouter( 16 | prefix='/tokens', 17 | ) 18 | 19 | 20 | @router.get("/{country_code}/{party_id}/{token_uid}", response_model=OCPIResponse) 21 | async def get_token(country_code: CiString(2), party_id: CiString(3), token_uid: CiString(36), 22 | request: Request, token_type: TokenType = TokenType.rfid, 23 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 24 | auth_token = get_auth_token(request) 25 | 26 | data = await crud.get(ModuleID.tokens, RoleEnum.cpo, token_uid, 27 | auth_token=auth_token, country_code=country_code, 28 | party_id=party_id, token_type=token_type, 29 | version=VersionNumber.v_2_2_1) 30 | return OCPIResponse( 31 | data=[adapter.token_adapter(data).dict()], 32 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 33 | ) 34 | 35 | 36 | @router.put("/{country_code}/{party_id}/{token_uid}", response_model=OCPIResponse) 37 | async def add_or_update_token(country_code: CiString(2), party_id: CiString(3), token_uid: CiString(36), token: Token, 38 | request: Request, token_type: TokenType = TokenType.rfid, 39 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 40 | auth_token = get_auth_token(request) 41 | 42 | data = await crud.get(ModuleID.tokens, RoleEnum.cpo, token_uid, auth_token=auth_token, 43 | token_type=token_type, country_code=country_code, party_id=party_id, 44 | version=VersionNumber.v_2_2_1) 45 | if data: 46 | data = await crud.update(ModuleID.tokens, RoleEnum.cpo, token.dict(), token_uid, token_type=token_type, 47 | auth_token=auth_token, country_code=country_code, 48 | party_id=party_id, version=VersionNumber.v_2_2_1) 49 | else: 50 | data = await crud.create(ModuleID.tokens, RoleEnum.cpo, token.dict(), token_type=token_type, 51 | auth_token=auth_token, country_code=country_code, 52 | party_id=party_id, version=VersionNumber.v_2_2_1) 53 | return OCPIResponse( 54 | data=[adapter.token_adapter(data).dict()], 55 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 56 | ) 57 | 58 | 59 | @router.patch("/{country_code}/{party_id}/{token_uid}", response_model=OCPIResponse) 60 | async def partial_update_token(country_code: CiString(2), party_id: CiString(3), token_uid: CiString(36), 61 | token: TokenPartialUpdate, request: Request, token_type: TokenType = TokenType.rfid, 62 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 63 | auth_token = get_auth_token(request) 64 | 65 | old_data = await crud.get(ModuleID.tokens, RoleEnum.cpo, token_uid, token_type=token_type, 66 | auth_token=auth_token, country_code=country_code, party_id=party_id, 67 | version=VersionNumber.v_2_2_1) 68 | old_token = adapter.token_adapter(old_data) 69 | 70 | new_token = old_token 71 | partially_update_attributes(new_token, token.dict(exclude_defaults=True, exclude_unset=True)) 72 | 73 | data = await crud.update(ModuleID.tokens, RoleEnum.cpo, new_token.dict(), token_uid, token_type=token_type, 74 | auth_token=auth_token, country_code=country_code, 75 | party_id=party_id, version=VersionNumber.v_2_2_1) 76 | return OCPIResponse( 77 | data=[adapter.token_adapter(data).dict()], 78 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 79 | ) 80 | -------------------------------------------------------------------------------- /tests/test_modules/test_credentials.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from uuid import uuid4 3 | from unittest.mock import patch 4 | from typing import Any 5 | 6 | import pytest 7 | from fastapi.testclient import TestClient 8 | from httpx import AsyncClient 9 | 10 | from py_ocpi import get_application 11 | from py_ocpi.core import enums 12 | from py_ocpi.core.data_types import URL 13 | from py_ocpi.core.config import settings 14 | from py_ocpi.core.dependencies import get_versions 15 | from py_ocpi.core.utils import encode_string_base64 16 | from py_ocpi.modules.credentials.v_2_2_1.schemas import Credentials 17 | from py_ocpi.modules.tokens.v_2_2_1.enums import AllowedType 18 | from py_ocpi.modules.tokens.v_2_2_1.schemas import AuthorizationInfo, Token 19 | from py_ocpi.modules.versions.enums import VersionNumber 20 | from py_ocpi.modules.versions.schemas import Version 21 | 22 | CREDENTIALS_TOKEN_GET = { 23 | 'url': 'url', 24 | 'roles': [{ 25 | 'role': enums.RoleEnum.emsp, 26 | 'business_details': { 27 | 'name': 'name', 28 | }, 29 | 'party_id': 'JOM', 30 | 'country_code': 'MY' 31 | }] 32 | } 33 | 34 | CREDENTIALS_TOKEN_CREATE = { 35 | 'token': str(uuid4()), 36 | 'url': '/ocpi/versions', 37 | 'roles': [{ 38 | 'role': enums.RoleEnum.emsp, 39 | 'business_details': { 40 | 'name': 'name', 41 | }, 42 | 'party_id': 'JOM', 43 | 'country_code': 'MY' 44 | }] 45 | } 46 | 47 | 48 | def partial_class(cls, *args, **kwds): 49 | 50 | class NewCls(cls): 51 | __init__ = functools.partialmethod(cls.__init__, *args, **kwds) 52 | 53 | return NewCls 54 | 55 | 56 | class Crud: 57 | @classmethod 58 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs): 59 | if id == CREDENTIALS_TOKEN_CREATE['token']: 60 | return None 61 | return dict(CREDENTIALS_TOKEN_GET, **{'token': id}) 62 | 63 | @classmethod 64 | async def create(cls, module: enums.ModuleID, data, operation, *args, **kwargs): 65 | if operation == 'credentials': 66 | return None 67 | return CREDENTIALS_TOKEN_CREATE 68 | 69 | @classmethod 70 | async def do(cls, module: enums.ModuleID, role: enums.RoleEnum, action: enums.Action, *args, 71 | data: dict = None, **kwargs): 72 | return None 73 | 74 | 75 | class Adapter: 76 | @classmethod 77 | def credentials_adapter(cls, data, version: VersionNumber = VersionNumber.latest) -> Credentials: 78 | return Credentials(**data) 79 | 80 | 81 | def test_cpo_get_credentials_v_2_2_1(): 82 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 83 | token = str(uuid4()) 84 | header = { 85 | "Authorization": f'Token {encode_string_base64(token)}' 86 | } 87 | 88 | client = TestClient(app) 89 | response = client.get('/ocpi/cpo/2.2.1/credentials', headers=header) 90 | 91 | assert response.status_code == 200 92 | assert response.json()['data']['token'] == token 93 | 94 | 95 | @pytest.mark.asyncio 96 | @patch('py_ocpi.modules.credentials.v_2_2_1.api.cpo.httpx.AsyncClient') 97 | async def test_cpo_post_credentials_v_2_2_1(async_client): 98 | class MockCrud(Crud): 99 | @classmethod 100 | async def do(cls, module: enums.ModuleID, role: enums.RoleEnum, action: enums.Action, auth_token, *args, data: dict = None, **kwargs) -> Any: 101 | return {} 102 | 103 | app_1 = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], MockCrud, Adapter) 104 | 105 | def override_get_versions(): 106 | return [ 107 | Version( 108 | version=VersionNumber.v_2_2_1, 109 | url=URL(f'/{settings.OCPI_PREFIX}/{VersionNumber.v_2_2_1.value}/details') 110 | ).dict() 111 | ] 112 | 113 | app_1.dependency_overrides[get_versions] = override_get_versions 114 | 115 | async_client.return_value = AsyncClient(app=app_1, base_url="http://test") 116 | 117 | 118 | app_2 = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], MockCrud, Adapter) 119 | 120 | async with AsyncClient(app=app_2, base_url="http://test") as client: 121 | response = await client.post('/ocpi/cpo/2.2.1/credentials/', json=CREDENTIALS_TOKEN_CREATE) 122 | 123 | assert response.status_code == 200 124 | assert response.json()['data']['token'] == CREDENTIALS_TOKEN_CREATE['token'] 125 | -------------------------------------------------------------------------------- /docs/tutorials/adapter.md: -------------------------------------------------------------------------------- 1 | # Adapter 2 | Adapter class has multiple adapter methods who will adapt the result from Crud to acceptable schema by OCPI. 3 | each module in OCPI must have one or more corresponding methods in Adapter class. 4 | 5 | The adapter methods are listed below: 6 | 7 | - **_location_adapter_** 8 | 9 | - **_description_**: 10 | 11 | the adapter method used in Locations module 12 | 13 | - **_input_**: 14 | 15 | data: The object details 16 | 17 | version: The version number of the caller OCPI module 18 | 19 | - **_output_**: [Location](https://github.com/ocpi/ocpi/blob/2.2.1/mod_locations.asciidoc#131-location-object) 20 | 21 | - **_session_adapter_** 22 | 23 | - **_description_**: 24 | 25 | the adapter method used in Sessions module 26 | 27 | - **_input_**: 28 | 29 | data: The object details 30 | 31 | version: The version number of the caller OCPI module 32 | 33 | - **_output_**: [Session](https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#131-session-object) 34 | 35 | - **_charging_preference_adapter_** 36 | 37 | - **_description_**: 38 | 39 | the adapter method used in Sessions module for charging preferences 40 | 41 | - **_input_**: 42 | 43 | data: The object details 44 | 45 | version: The version number of the caller OCPI module 46 | 47 | - **_output_**: [ChargingPreference](https://github.com/ocpi/ocpi/blob/2.2.1/mod_sessions.asciidoc#132-chargingpreferences-object) 48 | 49 | - **_credentials_adapter_** 50 | 51 | - **_description_**: 52 | 53 | the adapter method used in Credentials module 54 | 55 | - **_input_**: 56 | 57 | data: The object details 58 | 59 | version: The version number of the caller OCPI module 60 | 61 | - **_output_**: [Credential](https://github.com/ocpi/ocpi/blob/2.2.1/credentials.asciidoc#131-credentials-object) 62 | 63 | - **_cdr_adapter_** 64 | 65 | - **_description_**: 66 | 67 | the adapter method used in CDR module 68 | 69 | - **_input_**: 70 | 71 | data: The object details 72 | 73 | version: The version number of the caller OCPI module 74 | 75 | - **_output_**: [CDR](https://github.com/ocpi/ocpi/blob/2.2.1/mod_cdrs.asciidoc#131-cdr-object) 76 | 77 | - **_tariff_adapter_** 78 | 79 | - **_description_**: 80 | 81 | the adapter method used in Tariffs module 82 | 83 | - **_input_**: 84 | 85 | data: The object details 86 | 87 | version: The version number of the caller OCPI module 88 | 89 | - **_output_**: [Tariff](https://github.com/ocpi/ocpi/blob/2.2.1/mod_tariffs.asciidoc#131-tariff-object) 90 | 91 | - **_command_response_adapter_** 92 | 93 | - **_description_**: 94 | 95 | the adapter method used in Commands module for command response 96 | 97 | - **_input_**: 98 | 99 | data: The object details 100 | 101 | version: The version number of the caller OCPI module 102 | 103 | - **_output_**: [CommandResponse](https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#132-commandresponse-object) 104 | 105 | - **_command_result_adapter_** 106 | 107 | - **_description_**: 108 | 109 | the adapter method used in Commands module for command result 110 | 111 | - **_input_**: 112 | 113 | data: The object details 114 | 115 | version: The version number of the caller OCPI module 116 | 117 | - **_output_**: [CommandResult](https://github.com/ocpi/ocpi/blob/2.2.1/mod_commands.asciidoc#133-commandresult-object) 118 | 119 | - **_token_adapter_** 120 | 121 | - **_description_**: 122 | 123 | the adapter method used in Tokens module 124 | 125 | - **_input_**: 126 | 127 | data: The object details 128 | 129 | version: The version number of the caller OCPI module 130 | 131 | - **_output_**: [Token](https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#132-token-object) 132 | 133 | - **_authorization_adapter_** 134 | 135 | - **_description_**: 136 | 137 | the adapter method used in Tokens module for token authorization 138 | 139 | - **_input_**: 140 | 141 | data: The object details 142 | 143 | version: The version number of the caller OCPI module 144 | 145 | - **_output_**: [AuthorizationInfo](https://github.com/ocpi/ocpi/blob/2.2.1/mod_tokens.asciidoc#131-authorizationinfo-object) 146 | 147 | 148 | for instance the adapter for location module is _location_adapter_ as follows. 149 | 150 | 151 | ```python 152 | class Adapter: 153 | @classmethod 154 | def location_adapter(cls, data) -> Location: 155 | return Location(**data) 156 | ``` -------------------------------------------------------------------------------- /py_ocpi/core/adapter.py: -------------------------------------------------------------------------------- 1 | from py_ocpi.modules.versions.enums import VersionNumber 2 | 3 | 4 | class Adapter: 5 | @classmethod 6 | def location_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 7 | """Adapt the data to OCPI Location schema 8 | 9 | Args: 10 | data (dict): The object details 11 | version (VersionNumber, optional): The version number of the caller OCPI module 12 | 13 | Returns: 14 | Location: The object data in proper OCPI schema 15 | """ 16 | 17 | @classmethod 18 | def session_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 19 | """Adapt the data to OCPI Session schema 20 | 21 | Args: 22 | data (dict): The object details 23 | version (VersionNumber, optional): The version number of the caller OCPI module 24 | 25 | Returns: 26 | Session: The object data in proper OCPI schema 27 | """ 28 | 29 | @classmethod 30 | def charging_preference_adapter(cls, data: dict, 31 | version: VersionNumber = VersionNumber.latest): 32 | """Adapt the data to OCPI ChargingPreference schema 33 | 34 | Args: 35 | data (dict): The object details 36 | version (VersionNumber, optional): The version number of the caller OCPI module 37 | 38 | Returns: 39 | ChargingPreference: The object data in proper OCPI schema 40 | """ 41 | 42 | @classmethod 43 | def credentials_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 44 | """Adapt the data to OCPI Credential schema 45 | 46 | Args: 47 | data (dict): The object details 48 | version (VersionNumber, optional): The version number of the caller OCPI module 49 | 50 | Returns: 51 | Credential: The object data in proper OCPI schema 52 | """ 53 | 54 | @classmethod 55 | def cdr_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 56 | """Adapt the data to OCPI CDR schema 57 | 58 | Args: 59 | data (dict): The object details 60 | version (VersionNumber, optional): The version number of the caller OCPI module 61 | 62 | Returns: 63 | CDR: The object data in proper OCPI schema 64 | """ 65 | 66 | @classmethod 67 | def tariff_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 68 | """Adapt the data to OCPI Tariff schema 69 | 70 | Args: 71 | data (dict): The object details 72 | version (VersionNumber, optional): The version number of the caller OCPI module 73 | 74 | Returns: 75 | Tariff: The object data in proper OCPI schema 76 | """ 77 | 78 | @classmethod 79 | def command_response_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 80 | """Adapt the data to OCPI CommandResponse schema 81 | 82 | Args: 83 | data (dict): The object details 84 | version (VersionNumber, optional): The version number of the caller OCPI module 85 | 86 | Returns: 87 | CommandResponse: The object data in proper OCPI schema 88 | """ 89 | 90 | @classmethod 91 | def command_result_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 92 | """Adapt the data to OCPI CommandResult schema 93 | 94 | Args: 95 | data (dict): The object details 96 | version (VersionNumber, optional): The version number of the caller OCPI module 97 | 98 | Returns: 99 | CommandResult: The object data in proper OCPI schema 100 | """ 101 | 102 | @classmethod 103 | def token_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 104 | """Adapt the data to OCPI Token schema 105 | 106 | Args: 107 | data (dict): The object details 108 | version (VersionNumber, optional): The version number of the caller OCPI module 109 | 110 | Returns: 111 | Token: The object data in proper OCPI schema 112 | """ 113 | 114 | @classmethod 115 | def authorization_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 116 | """Adapt the data to OCPI AuthorizationInfo schema 117 | 118 | Args: 119 | data (dict): The object details 120 | version (VersionNumber, optional): The version number of the caller OCPI module 121 | 122 | Returns: 123 | AuthorizationInfo: The object data in proper OCPI schema 124 | """ 125 | -------------------------------------------------------------------------------- /py_ocpi/modules/commands/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | 3 | from fastapi import APIRouter, BackgroundTasks, Depends, Request, status as fastapistatus 4 | from fastapi.encoders import jsonable_encoder 5 | from fastapi.responses import JSONResponse 6 | from pydantic import ValidationError 7 | import httpx 8 | 9 | from py_ocpi.core.dependencies import get_crud, get_adapter 10 | from py_ocpi.core.enums import ModuleID, RoleEnum, Action 11 | from py_ocpi.core.exceptions import NotFoundOCPIError 12 | from py_ocpi.core.schemas import OCPIResponse 13 | from py_ocpi.core.adapter import Adapter 14 | from py_ocpi.core.crud import Crud 15 | from py_ocpi.core import status 16 | from py_ocpi.core.utils import encode_string_base64, get_auth_token 17 | from py_ocpi.modules.versions.enums import VersionNumber 18 | from py_ocpi.modules.commands.v_2_2_1.enums import CommandType 19 | from py_ocpi.modules.commands.v_2_2_1.schemas import ( 20 | CancelReservation, ReserveNow, StartSession, 21 | StopSession, UnlockConnector, CommandResult, 22 | CommandResultType, CommandResponse, CommandResponseType 23 | ) 24 | 25 | router = APIRouter( 26 | prefix='/commands', 27 | ) 28 | 29 | 30 | async def apply_pydantic_schema(command: str, data: dict): 31 | if command == CommandType.reserve_now: 32 | data = ReserveNow(**data) 33 | elif command == CommandType.cancel_reservation: 34 | data = CancelReservation(**data) 35 | elif command == CommandType.start_session: 36 | data = StartSession(**data) 37 | elif command == CommandType.stop_session: 38 | data = StopSession(**data) 39 | else: 40 | data = UnlockConnector(**data) 41 | return data 42 | 43 | 44 | async def send_command_result(response_url: str, command: CommandType, auth_token: str, crud: Crud, adapter: Adapter): 45 | client_auth_token = await crud.do(ModuleID.commands, RoleEnum.cpo, Action.get_client_token, 46 | auth_token=auth_token, version=VersionNumber.v_2_2_1) 47 | 48 | for _ in range(150): # check for 5 mins 49 | # since command has no id, 0 is used for id parameter of crud.get 50 | command_result = await crud.get(ModuleID.commands, RoleEnum.cpo, 0, 51 | auth_token=auth_token, version=VersionNumber.v_2_2_1, command=command) 52 | if command_result: 53 | break 54 | await sleep(2) 55 | 56 | if not command_result: 57 | command_result = CommandResult(result=CommandResultType.failed) 58 | else: 59 | command_result = adapter.command_result_adapter(command_result, VersionNumber.v_2_2_1) 60 | 61 | async with httpx.AsyncClient() as client: 62 | authorization_token = f'Token {encode_string_base64(client_auth_token)}' 63 | await client.post(response_url, json=command_result.dict(), headers={'authorization': authorization_token}) 64 | 65 | 66 | @router.post("/{command}", response_model=OCPIResponse) 67 | async def receive_command(request: Request, command: CommandType, data: dict, background_tasks: BackgroundTasks, 68 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 69 | auth_token = get_auth_token(request) 70 | 71 | try: 72 | command_data = await apply_pydantic_schema(command, data) 73 | except ValidationError as exc: 74 | return JSONResponse( 75 | status_code=fastapistatus.HTTP_422_UNPROCESSABLE_ENTITY, 76 | content={'detail': jsonable_encoder(exc.errors())} 77 | ) 78 | 79 | try: 80 | if hasattr(command_data, 'location_id'): 81 | await crud.get(ModuleID.locations, RoleEnum.cpo, command_data.location_id, auth_token=auth_token, 82 | version=VersionNumber.v_2_2_1) 83 | 84 | command_response = await crud.do(ModuleID.commands, RoleEnum.cpo, Action.send_command, command_data.dict(), 85 | command=command, auth_token=auth_token, version=VersionNumber.v_2_2_1) 86 | 87 | background_tasks.add_task(send_command_result, response_url=command_data.response_url, 88 | command=command, auth_token=auth_token, crud=crud, adapter=adapter) 89 | 90 | return OCPIResponse( 91 | data=[adapter.command_response_adapter(command_response).dict()], 92 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 93 | ) 94 | 95 | # when the location is not found 96 | except NotFoundOCPIError: 97 | command_response = CommandResponse(result=CommandResponseType.rejected, timeout=0) 98 | return OCPIResponse( 99 | data=[command_response.dict()], 100 | **status.OCPI_2003_UNKNOWN_LOCATION, 101 | ) 102 | -------------------------------------------------------------------------------- /docs/tutorials/crud.md: -------------------------------------------------------------------------------- 1 | # CRUD 2 | The CRUD class is responsible of getting the required info for proper OCPI communication. for each OCPI API call, the corresponding method from Crud class will be called. 3 | 4 | The CRUD methods are listed below: 5 | 6 | - **_get_** 7 | 8 | - **_description_**: 9 | 10 | used for getting a data object 11 | 12 | - **_input_**: 13 | 14 | module: The OCPI module 15 | 16 | role: The role of the caller 17 | 18 | id: The ID of the object 19 | 20 | auth_token: The authentication token used by third party 21 | 22 | version: The version number of the caller OCPI module 23 | 24 | party_id: The requested party ID 25 | 26 | country_code: The requested Country code 27 | 28 | token_type: The token type 29 | 30 | command: The command type of the OCPP command 31 | 32 | > **_NOTE:_** party_id, country_code, token_type, command are only present when a module pass them 33 | 34 | - **_output_**: the object data in dict 35 | 36 | - **_list_** 37 | 38 | - **_description_**: 39 | 40 | used for getting the list of data objects 41 | 42 | - **_input_**: 43 | 44 | module: The OCPI module 45 | 46 | role: The role of the caller 47 | 48 | filters: [OCPI pagination filters](https://github.com/ocpi/ocpi/blob/master/transport_and_format.asciidoc#paginated-request) 49 | 50 | auth_token: The authentication token used by third party 51 | 52 | version: The version number of the caller OCPI module 53 | 54 | party_id: The requested party ID 55 | 56 | country_code: The requested Country code 57 | 58 | > **_NOTE:_** party_id and country_code are only present when a module pass them 59 | 60 | - **_output_**: a tuple containing Objects list, Total number of objects and if it's the last page or not(for pagination) (list, int, bool) 61 | 62 | - **_create_** 63 | 64 | - **_description_**: 65 | 66 | used for creating a data object 67 | 68 | - **_input_**: 69 | 70 | module: The OCPI module 71 | 72 | role: The role of the caller 73 | 74 | data: The object details 75 | 76 | auth_token: The authentication token used by third party 77 | 78 | version: The version number of the caller OCPI module 79 | 80 | party_id: The requested party ID 81 | 82 | country_code: The requested Country code 83 | 84 | token_type: The token type 85 | 86 | command: The command type (used in Commands module) 87 | 88 | operation : The operation type in credentials and registration process the value is either 'credentials' or 'registration' 89 | 90 | > **_NOTE:_** party_id, country_code and token_type are only present when a module pass them 91 | 92 | - **_output_**: the newly created object data in dict 93 | 94 | - **_update_** 95 | 96 | - **_description_**: 97 | 98 | used for updating a data object 99 | 100 | - **_input_**: 101 | 102 | module: The OCPI module 103 | 104 | role: The role of the caller 105 | 106 | data: The object details 107 | 108 | id: The ID of the object 109 | 110 | auth_token: The authentication token used by third party 111 | 112 | version: The version number of the caller OCPI module 113 | 114 | party_id: The requested party ID 115 | 116 | country_code: The requested Country code 117 | 118 | token_type: The token type 119 | 120 | operation : The operation type in credentials and registration process the value is either 'credentials' or 'registration' 121 | 122 | - **_output_**: the updated object data in dict 123 | 124 | - **_delete_** 125 | 126 | - **_description_**: 127 | 128 | used for deleting a data object 129 | 130 | - **_input_**: 131 | 132 | module: The OCPI module 133 | 134 | role: The role of the caller 135 | 136 | id: The ID of the object 137 | 138 | auth_token: The authentication token used by third party 139 | 140 | version: The version number of the caller OCPI module 141 | 142 | - **_output_**: None 143 | 144 | - **_do_** 145 | 146 | - **_description_**: 147 | 148 | used for doing an action or a non-CRUD operation 149 | 150 | - **_input_**: 151 | 152 | module: The OCPI module 153 | 154 | role: The role of the caller 155 | 156 | action: The action type. it can be either 'SendCommand', 'GetClientToken' or 'AuthorizeToken' 157 | 158 | data: The data required for the action 159 | 160 | command: The command type of the OCPP command 161 | 162 | auth_token: The authentication token used by third party 163 | 164 | version: The version number of the caller OCPI module 165 | 166 | - **_output_**: The action result in dict 167 | -------------------------------------------------------------------------------- /py_ocpi/main.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from fastapi import FastAPI, Request, HTTPException 4 | from fastapi.responses import JSONResponse 5 | from fastapi.middleware.cors import CORSMiddleware 6 | from pydantic import ValidationError 7 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 8 | from py_ocpi.core.endpoints import ENDPOINTS 9 | 10 | from py_ocpi.modules.versions.api import router as versions_router, versions_v_2_2_1_router 11 | from py_ocpi.modules.versions.enums import VersionNumber 12 | from py_ocpi.modules.versions.schemas import Version 13 | from py_ocpi.core.dependencies import get_crud, get_adapter, get_versions, get_endpoints 14 | from py_ocpi.core import status 15 | from py_ocpi.core.enums import RoleEnum 16 | from py_ocpi.core.config import settings 17 | from py_ocpi.core.data_types import URL 18 | from py_ocpi.core.schemas import OCPIResponse 19 | from py_ocpi.core.exceptions import AuthorizationOCPIError, NotFoundOCPIError 20 | from py_ocpi.core.push import http_router as http_push_router, websocket_router as websocket_push_router 21 | from py_ocpi.routers import v_2_2_1_cpo_router, v_2_2_1_emsp_router 22 | 23 | 24 | class ExceptionHandlerMiddleware(BaseHTTPMiddleware): 25 | 26 | async def dispatch( 27 | self, request: Request, call_next: RequestResponseEndpoint 28 | ): 29 | try: 30 | response = await call_next(request) 31 | except AuthorizationOCPIError as e: 32 | raise HTTPException(403, str(e)) from e 33 | except NotFoundOCPIError as e: 34 | raise HTTPException(404, str(e)) from e 35 | except ValidationError: 36 | response = JSONResponse( 37 | OCPIResponse( 38 | data=[], 39 | **status.OCPI_3000_GENERIC_SERVER_ERROR, 40 | ).dict() 41 | ) 42 | return response 43 | 44 | 45 | def get_application( 46 | version_numbers: List[VersionNumber], 47 | roles: List[RoleEnum], 48 | crud: Any, 49 | adapter: Any, 50 | http_push: bool = False, 51 | websocket_push: bool = False, 52 | ) -> FastAPI: 53 | _app = FastAPI( 54 | title=settings.PROJECT_NAME, 55 | docs_url=f'/{settings.OCPI_PREFIX}/docs', 56 | openapi_url=f"/{settings.OCPI_PREFIX}/openapi.json" 57 | ) 58 | 59 | _app.add_middleware( 60 | CORSMiddleware, 61 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 62 | allow_credentials=True, 63 | allow_methods=["*"], 64 | allow_headers=["*"], 65 | ) 66 | _app.add_middleware(ExceptionHandlerMiddleware) 67 | 68 | _app.include_router( 69 | versions_router, 70 | prefix=f'/{settings.OCPI_PREFIX}', 71 | ) 72 | 73 | if http_push: 74 | _app.include_router( 75 | http_push_router, 76 | prefix=f'/{settings.PUSH_PREFIX}', 77 | ) 78 | 79 | if websocket_push: 80 | _app.include_router( 81 | websocket_push_router, 82 | prefix=f'/{settings.PUSH_PREFIX}', 83 | ) 84 | 85 | versions = [] 86 | version_endpoints = {} 87 | 88 | if VersionNumber.v_2_2_1 in version_numbers: 89 | _app.include_router( 90 | versions_v_2_2_1_router, 91 | prefix=f'/{settings.OCPI_PREFIX}', 92 | ) 93 | 94 | versions.append( 95 | Version( 96 | version=VersionNumber.v_2_2_1, 97 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/{VersionNumber.v_2_2_1.value}/details') 98 | ).dict(), 99 | ) 100 | 101 | version_endpoints[VersionNumber.v_2_2_1] = [] 102 | 103 | if RoleEnum.cpo in roles: 104 | _app.include_router( 105 | v_2_2_1_cpo_router, 106 | prefix=f'/{settings.OCPI_PREFIX}/cpo/{VersionNumber.v_2_2_1.value}', 107 | tags=['CPO'] 108 | ) 109 | version_endpoints[VersionNumber.v_2_2_1] += ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.cpo] 110 | 111 | if RoleEnum.emsp in roles: 112 | _app.include_router( 113 | v_2_2_1_emsp_router, 114 | prefix=f'/{settings.OCPI_PREFIX}/emsp/{VersionNumber.v_2_2_1.value}', 115 | tags=['EMSP'] 116 | ) 117 | version_endpoints[VersionNumber.v_2_2_1] += ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.emsp] 118 | 119 | def override_get_crud(): 120 | return crud 121 | 122 | _app.dependency_overrides[get_crud] = override_get_crud 123 | 124 | def override_get_adapter(): 125 | return adapter 126 | 127 | _app.dependency_overrides[get_adapter] = override_get_adapter 128 | 129 | def override_get_versions(): 130 | return versions 131 | 132 | _app.dependency_overrides[get_versions] = override_get_versions 133 | 134 | def override_get_endpoints(): 135 | return version_endpoints 136 | 137 | _app.dependency_overrides[get_endpoints] = override_get_endpoints 138 | 139 | return _app 140 | -------------------------------------------------------------------------------- /py_ocpi/core/endpoints.py: -------------------------------------------------------------------------------- 1 | from py_ocpi.core.enums import ModuleID, RoleEnum 2 | from py_ocpi.core.data_types import URL 3 | from py_ocpi.core.config import settings 4 | from py_ocpi.modules.versions.schemas import Endpoint 5 | from py_ocpi.modules.versions.enums import VersionNumber, InterfaceRole 6 | 7 | ENDPOINTS = { 8 | VersionNumber.v_2_2_1: { 9 | # ###############--CPO--############### 10 | RoleEnum.cpo: [ 11 | # locations 12 | Endpoint( 13 | identifier=ModuleID.locations, 14 | role=InterfaceRole.sender, 15 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/cpo' 16 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.locations.value}') 17 | ), 18 | # sessions 19 | Endpoint( 20 | identifier=ModuleID.sessions, 21 | role=InterfaceRole.sender, 22 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/cpo' 23 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.sessions.value}') 24 | ), 25 | # credentials 26 | Endpoint( 27 | identifier=ModuleID.credentials_and_registration, 28 | role=InterfaceRole.receiver, 29 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/cpo' 30 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.credentials_and_registration.value}') 31 | ), 32 | # tariffs 33 | Endpoint( 34 | identifier=ModuleID.tariffs, 35 | role=InterfaceRole.sender, 36 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/cpo' 37 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.tariffs.value}') 38 | ), 39 | # cdrs 40 | Endpoint( 41 | identifier=ModuleID.cdrs, 42 | role=InterfaceRole.sender, 43 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/cpo' 44 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.cdrs.value}') 45 | ), 46 | # tokens 47 | Endpoint( 48 | identifier=ModuleID.tokens, 49 | role=InterfaceRole.receiver, 50 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/cpo' 51 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.tokens.value}') 52 | ), 53 | ], 54 | 55 | # ###############--EMSP--############### 56 | RoleEnum.emsp: [ 57 | # credentials 58 | Endpoint( 59 | identifier=ModuleID.credentials_and_registration, 60 | role=InterfaceRole.receiver, 61 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 62 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.credentials_and_registration.value}') 63 | ), 64 | # locations 65 | Endpoint( 66 | identifier=ModuleID.locations, 67 | role=InterfaceRole.receiver, 68 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 69 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.locations.value}') 70 | ), 71 | # sessions 72 | Endpoint( 73 | identifier=ModuleID.sessions, 74 | role=InterfaceRole.receiver, 75 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 76 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.sessions.value}') 77 | ), 78 | # cdrs 79 | Endpoint( 80 | identifier=ModuleID.cdrs, 81 | role=InterfaceRole.receiver, 82 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 83 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.cdrs.value}') 84 | ), 85 | # tariffs 86 | Endpoint( 87 | identifier=ModuleID.tariffs, 88 | role=InterfaceRole.receiver, 89 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 90 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.tariffs.value}') 91 | ), 92 | # commands 93 | Endpoint( 94 | identifier=ModuleID.commands, 95 | role=InterfaceRole.sender, 96 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 97 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.commands.value}') 98 | ), 99 | # tokens 100 | Endpoint( 101 | identifier=ModuleID.tokens, 102 | role=InterfaceRole.sender, 103 | url=URL(f'https://{settings.OCPI_HOST}/{settings.OCPI_PREFIX}/emsp' 104 | f'/{VersionNumber.v_2_2_1.value}/{ModuleID.tokens.value}') 105 | ), 106 | ] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /py_ocpi/core/crud.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple 2 | 3 | from py_ocpi.core.enums import ModuleID, RoleEnum, Action 4 | 5 | 6 | class Crud: 7 | @classmethod 8 | async def get(cls, module: ModuleID, role: RoleEnum, id, *args, **kwargs) -> Any: 9 | """Get an object 10 | 11 | Args: 12 | module (ModuleID): The OCPI module 13 | role (RoleEnum): The role of the caller 14 | id (Any): The ID of the object 15 | 16 | Keyword Args: 17 | auth_token (str): The authentication token used by third party 18 | version (VersionNumber): The version number of the caller OCPI module 19 | party_id (CiString(3)): The requested party ID 20 | country_code (CiString(2)): The requested Country code 21 | token_type (TokenType): The token type 22 | command (CommandType): The command type of the OCPP command 23 | 24 | Returns: 25 | Any: The object data 26 | """ 27 | 28 | @classmethod 29 | async def list(cls, module: ModuleID, role: RoleEnum, filters: dict, *args, **kwargs) -> Tuple[list, int, bool]: 30 | """Get the list of objects 31 | 32 | Args: 33 | module (ModuleID): The OCPI module 34 | role (RoleEnum): The role of the caller 35 | filters (dict): OCPI pagination filters 36 | 37 | Keyword Args: 38 | auth_token (str): The authentication token used by third party 39 | version (VersionNumber): The version number of the caller OCPI module 40 | party_id (CiString(3)): The requested party ID 41 | country_code (CiString(2)): The requested Country code 42 | 43 | Returns: 44 | Tuple[list, int, bool]: Objects list, Total number of objects, if it's the last page or not(for pagination) 45 | """ 46 | 47 | @classmethod 48 | async def create(cls, module: ModuleID, role: RoleEnum, data: dict, *args, **kwargs) -> Any: 49 | """Create an object 50 | 51 | Args: 52 | module (ModuleID): The OCPI module 53 | role (RoleEnum): The role of the caller 54 | data (dict): The object details 55 | 56 | Keyword Args: 57 | auth_token (str): The authentication token used by third party 58 | version (VersionNumber): The version number of the caller OCPI module 59 | command (CommandType): The command type (used in Commands module) 60 | party_id (CiString(3)): The requested party ID 61 | country_code (CiString(2)): The requested Country code 62 | token_type (TokenType): The token type 63 | operation ('credentials', 'registration'): The operation type in credentials and registration process 64 | 65 | Returns: 66 | Any: The created object data 67 | """ 68 | 69 | @classmethod 70 | async def update(cls, module: ModuleID, role: RoleEnum, data: dict, id, *args, **kwargs) -> Any: 71 | """Update an object 72 | 73 | Args: 74 | module (ModuleID): The OCPI module 75 | role (RoleEnum): The role of the caller 76 | data (dict): The object details 77 | id (Any): The ID of the object 78 | 79 | Keyword Args: 80 | auth_token (str): The authentication token used by third party 81 | version (VersionNumber): The version number of the caller OCPI module 82 | party_id (CiString(3)): The requested party ID 83 | country_code (CiString(2)): The requested Country code 84 | token_type (TokenType): The token type 85 | operation ('credentials', 'registration'): The operation type in credentials and registration process 86 | 87 | 88 | Returns: 89 | Any: The updated object data 90 | """ 91 | 92 | @classmethod 93 | async def delete(cls, module: ModuleID, role: RoleEnum, id, *args, **kwargs): 94 | """Delete an object 95 | 96 | Args: 97 | module (ModuleID): The OCPI module 98 | role (RoleEnum): The role of the caller 99 | id (Any): The ID of the object 100 | 101 | Keyword Args: 102 | auth_token (str): The authentication token used by third party 103 | version (VersionNumber): The version number of the caller OCPI module 104 | """ 105 | 106 | @classmethod 107 | async def do(cls, module: ModuleID, role: RoleEnum, action: Action, *args, data: dict = None, **kwargs) -> Any: 108 | """Do an action (non-CRUD) 109 | 110 | Args: 111 | module (ModuleID): The OCPI module 112 | role (RoleEnum): The role of the caller 113 | action (Action): The action type 114 | data (dict): The data required for the action 115 | command (CommandType): The command type of the OCPP command 116 | 117 | Keyword Args: 118 | auth_token (str): The authentication token used by third party 119 | version (VersionNumber): The version number of the caller OCPI module 120 | 121 | Returns: 122 | Any: The action result 123 | """ 124 | -------------------------------------------------------------------------------- /tests/test_modules/test_sessions.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi.main import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.core.config import settings 8 | from py_ocpi.modules.cdrs.v_2_2_1.schemas import TokenType 9 | from py_ocpi.modules.cdrs.v_2_2_1.enums import AuthMethod, CdrDimensionType 10 | from py_ocpi.modules.sessions.v_2_2_1.schemas import Session, ChargingPreferences 11 | from py_ocpi.modules.sessions.v_2_2_1.enums import SessionStatus, ProfileType 12 | from py_ocpi.modules.versions.enums import VersionNumber 13 | 14 | SESSIONS = [ 15 | { 16 | 'country_code': 'us', 17 | 'party_id': 'AAA', 18 | 'id': str(uuid4()), 19 | 'start_date_time': '2022-01-02 00:00:00+00:00', 20 | 'end_date_time': '2022-01-02 00:05:00+00:00', 21 | 'kwh': 100, 22 | 'cdr_token': { 23 | 'country_code': 'us', 24 | 'party_id': 'AAA', 25 | 'uid': str(uuid4()), 26 | 'type': TokenType.rfid, 27 | 'contract_id': str(uuid4()) 28 | }, 29 | 'auth_method': AuthMethod.auth_request, 30 | 'location_id': str(uuid4()), 31 | 'evse_uid': str(uuid4()), 32 | 'connector_id': str(uuid4()), 33 | 'currency': 'MYR', 34 | 'charging_periods': [ 35 | { 36 | 'start_date_time': '2022-01-02 00:00:00+00:00', 37 | 'dimensions': [ 38 | { 39 | 'type': CdrDimensionType.power, 40 | 'volume': 10 41 | } 42 | ] 43 | } 44 | ], 45 | 'total_cost': { 46 | 'excl_vat': 10.0000, 47 | 'incl_vat': 10.2500 48 | }, 49 | 'status': SessionStatus.active, 50 | 'last_updated': '2022-01-02 00:00:00+00:00' 51 | } 52 | ] 53 | 54 | CHARGING_PREFERENCES = { 55 | 'profile_type': ProfileType.fast, 56 | 'departure_time': '2022-01-02 00:00:00+00:00', 57 | 'energy_need': 100 58 | } 59 | 60 | 61 | class Crud: 62 | 63 | @classmethod 64 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs): 65 | return SESSIONS[0] 66 | 67 | @classmethod 68 | async def update(cls, module: enums.ModuleID, role: enums.RoleEnum, data: dict, id, *args, **kwargs): 69 | return data 70 | 71 | @classmethod 72 | async def create(cls, module: enums.ModuleID, role: enums.RoleEnum, data: dict, *args, **kwargs): 73 | return data 74 | 75 | @classmethod 76 | async def list(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> list: 77 | return SESSIONS, 1, True 78 | 79 | 80 | class Adapter: 81 | @classmethod 82 | def session_adapter(cls, data, version: VersionNumber = VersionNumber.latest) -> Session: 83 | return Session(**data) 84 | 85 | @classmethod 86 | def charging_preference_adapter(cls, data, version: VersionNumber = VersionNumber.latest) -> Session: 87 | return ChargingPreferences(**data) 88 | 89 | 90 | def test_cpo_get_sessions_v_2_2_1(): 91 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 92 | 93 | client = TestClient(app) 94 | response = client.get('/ocpi/cpo/2.2.1/sessions') 95 | 96 | assert response.status_code == 200 97 | assert len(response.json()['data']) == 1 98 | assert response.json()['data'][0]['id'] == SESSIONS[0]["id"] 99 | 100 | 101 | def test_cpo_set_charging_preference_v_2_2_1(): 102 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 103 | 104 | client = TestClient(app) 105 | response = client.put(f'/ocpi/cpo/2.2.1/sessions/{SESSIONS[0]["id"]}/charging_preferences', 106 | json=CHARGING_PREFERENCES) 107 | 108 | assert response.status_code == 200 109 | assert len(response.json()['data']) == 1 110 | assert response.json()['data'][0]['energy_need'] == CHARGING_PREFERENCES["energy_need"] 111 | 112 | 113 | def test_emsp_get_session_v_2_2_1(): 114 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 115 | 116 | client = TestClient(app) 117 | response = client.get(f'/ocpi/emsp/2.2.1/sessions/{settings.COUNTRY_CODE}/{settings.PARTY_ID}' 118 | f'/{SESSIONS[0]["id"]}') 119 | 120 | assert response.status_code == 200 121 | assert response.json()['data'][0]['id'] == SESSIONS[0]["id"] 122 | 123 | 124 | def test_emsp_add_session_v_2_2_1(): 125 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 126 | 127 | client = TestClient(app) 128 | response = client.put(f'/ocpi/emsp/2.2.1/sessions/{settings.COUNTRY_CODE}/{settings.PARTY_ID}' 129 | f'/{SESSIONS[0]["id"]}', json=SESSIONS[0]) 130 | 131 | assert response.status_code == 200 132 | assert response.json()['data'][0]['id'] == SESSIONS[0]["id"] 133 | 134 | 135 | def test_emsp_patch_session_v_2_2_1(): 136 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 137 | 138 | patch_data = {'id': str(uuid4())} 139 | client = TestClient(app) 140 | response = client.patch(f'/ocpi/emsp/2.2.1/sessions/{settings.COUNTRY_CODE}/{settings.PARTY_ID}' 141 | f'/{SESSIONS[0]["id"]}', json=patch_data) 142 | 143 | assert response.status_code == 200 144 | assert response.json()['data'][0]['id'] == patch_data["id"] 145 | -------------------------------------------------------------------------------- /py_ocpi/core/push.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from fastapi import APIRouter, Request, WebSocket, Depends 3 | 4 | from py_ocpi.core.adapter import Adapter 5 | from py_ocpi.core.crud import Crud 6 | from py_ocpi.core.schemas import Push, PushResponse, ReceiverResponse 7 | from py_ocpi.core.utils import encode_string_base64, get_auth_token 8 | from py_ocpi.core.dependencies import get_crud, get_adapter 9 | from py_ocpi.core.enums import ModuleID, RoleEnum 10 | from py_ocpi.core.config import settings 11 | from py_ocpi.modules.versions.enums import InterfaceRole, VersionNumber 12 | 13 | 14 | def client_url(module_id: ModuleID, object_id: str, base_url: str) -> str: 15 | if module_id == ModuleID.cdrs: 16 | return base_url 17 | return f'{base_url}/{settings.COUNTRY_CODE}/{settings.PARTY_ID}/{object_id}' 18 | 19 | 20 | def client_method(module_id: ModuleID) -> str: 21 | if module_id == ModuleID.cdrs: 22 | return 'POST' 23 | return 'PUT' 24 | 25 | 26 | def request_data(module_id: ModuleID, object_data: dict, adapter: Adapter) -> dict: 27 | data = {} 28 | if module_id == ModuleID.locations: 29 | data = adapter.location_adapter(object_data).dict() 30 | elif module_id == ModuleID.sessions: 31 | data = adapter.session_adapter(object_data).dict() 32 | elif module_id == ModuleID.cdrs: 33 | data = adapter.cdr_adapter(object_data).dict() 34 | elif module_id == ModuleID.tariffs: 35 | data = adapter.tariff_adapter(object_data).dict() 36 | elif module_id == ModuleID.tokens: 37 | data = adapter.token_adapter(object_data).dict() 38 | return data 39 | 40 | 41 | async def send_push_request( 42 | object_id: str, 43 | object_data: dict, 44 | module_id: ModuleID, 45 | adapter: Adapter, 46 | client_auth_token: str, 47 | endpoints: list, 48 | ): 49 | data = request_data(module_id, object_data, adapter) 50 | 51 | base_url = '' 52 | for endpoint in endpoints: 53 | if endpoint['identifier'] == module_id and endpoint['role'] == InterfaceRole.receiver: 54 | base_url = endpoint['url'] 55 | 56 | # push object to client 57 | async with httpx.AsyncClient() as client: 58 | request = client.build_request(client_method(module_id), client_url(module_id, object_id, base_url), 59 | headers={'authorization': client_auth_token}, json=data) 60 | response = await client.send(request) 61 | return response 62 | 63 | 64 | async def push_object(version: VersionNumber, push: Push, crud: Crud, adapter: Adapter, 65 | auth_token: str = None) -> PushResponse: 66 | receiver_responses = [] 67 | for receiver in push.receivers: 68 | # get client endpoints 69 | client_auth_token = f'Token {encode_string_base64(receiver.auth_token)}' 70 | async with httpx.AsyncClient() as client: 71 | response = await client.get(receiver.endpoints_url, 72 | headers={'authorization': client_auth_token}) 73 | endpoints = response.json()['data'][0]['endpoints'] 74 | 75 | # get object data 76 | if push.module_id == ModuleID.tokens: 77 | data = await crud.get(push.module_id, RoleEnum.emsp, push.object_id, 78 | auth_token=auth_token, version=version) 79 | else: 80 | data = await crud.get(push.module_id, RoleEnum.cpo, push.object_id, 81 | auth_token=auth_token, version=version) 82 | 83 | response = await send_push_request(push.object_id, data, push.module_id, adapter, client_auth_token, endpoints) 84 | if push.module_id == ModuleID.cdrs: 85 | receiver_responses.append(ReceiverResponse(endpoints_url=receiver.endpoints_url, 86 | status_code=response.status_code, 87 | response=response.headers)) 88 | else: 89 | receiver_responses.append(ReceiverResponse(endpoints_url=receiver.endpoints_url, 90 | status_code=response.status_code, 91 | response=response.json())) 92 | 93 | return PushResponse(receiver_responses=receiver_responses) 94 | 95 | 96 | http_router = APIRouter() 97 | 98 | 99 | # WARNING it's advised not to expose this endpoint 100 | @http_router.post("/{version}", status_code=200, include_in_schema=False, response_model=PushResponse) 101 | async def http_push_to_client(request: Request, version: VersionNumber, push: Push, 102 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 103 | auth_token = get_auth_token(request) 104 | 105 | return await push_object(version, push, crud, adapter, auth_token) 106 | 107 | 108 | websocket_router = APIRouter() 109 | 110 | 111 | # WARNING it's advised not to expose this endpoint 112 | @websocket_router.websocket("/ws/{version}") 113 | async def websocket_push_to_client(websocket: WebSocket, version: VersionNumber, 114 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 115 | 116 | auth_token = get_auth_token(websocket) 117 | await websocket.accept() 118 | 119 | while True: 120 | data = await websocket.receive_json() 121 | push = Push(**data) 122 | push_response = await push_object(version, push, crud, adapter, auth_token) 123 | await websocket.send_json(push_response.dict()) 124 | -------------------------------------------------------------------------------- /tests/test_modules/test_tokens.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from py_ocpi.main import get_application 6 | from py_ocpi.core import enums 7 | from py_ocpi.core.exceptions import NotFoundOCPIError 8 | from py_ocpi.modules.cdrs.v_2_2_1.enums import AuthMethod 9 | from py_ocpi.modules.tokens.v_2_2_1.enums import WhitelistType, TokenType, AllowedType 10 | from py_ocpi.modules.tokens.v_2_2_1.schemas import AuthorizationInfo, Token 11 | from py_ocpi.modules.versions.enums import VersionNumber 12 | 13 | TOKENS = [ 14 | { 15 | 'country_code': 'us', 16 | 'party_id': 'AAA', 17 | 'uid': str(uuid4()), 18 | 'type': TokenType.rfid, 19 | 'contract_id': str(uuid4()), 20 | 'issuer': 'issuer', 21 | 'auth_method': AuthMethod.auth_request, 22 | 'valid': True, 23 | 'whitelist': WhitelistType.always, 24 | 'last_updated': '2022-01-02 00:00:00+00:00' 25 | } 26 | ] 27 | 28 | TOKEN_UPDATE = { 29 | 'country_code': 'pl', 30 | 'party_id': 'BBB', 31 | 'last_updated': '2022-01-02 00:00:00+00:00' 32 | } 33 | 34 | 35 | class Crud: 36 | 37 | @classmethod 38 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> Token: 39 | return TOKENS[0] 40 | 41 | @classmethod 42 | async def create(cls, module: enums.ModuleID, role: enums.RoleEnum, data: Token, *args, **kwargs) -> dict: 43 | return data 44 | 45 | @classmethod 46 | async def do(cls, module: enums.ModuleID, role: enums.RoleEnum, action: enums.Action, *args, 47 | data: dict = None, **kwargs): 48 | return AuthorizationInfo( 49 | allowed=AllowedType.allowed, 50 | token=Token(**TOKENS[0]) 51 | ).dict() 52 | 53 | @classmethod 54 | async def list(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> list: 55 | return TOKENS, 1, True 56 | 57 | @classmethod 58 | async def update(cls, module: enums.ModuleID, role: enums.RoleEnum, data: Token, id: str, *args, **kwargs): 59 | data = dict(data) 60 | TOKENS[0]['country_code'] = data['country_code'] 61 | TOKENS[0]['party_id'] = data['party_id'] 62 | return TOKENS[0] 63 | 64 | 65 | class Adapter: 66 | @classmethod 67 | def token_adapter(cls, data, version: VersionNumber = VersionNumber.latest) -> Token: 68 | return Token(**dict(data)) 69 | 70 | @classmethod 71 | def authorization_adapter(cls, data: dict, version: VersionNumber = VersionNumber.latest): 72 | return AuthorizationInfo(**data) 73 | 74 | 75 | def test_cpo_get_token_v_2_2_1(): 76 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 77 | 78 | client = TestClient(app) 79 | response = client.get(f'/ocpi/cpo/2.2.1/tokens/{TOKENS[0]["country_code"]}/{TOKENS[0]["party_id"]}/' 80 | f'{TOKENS[0]["uid"]}') 81 | 82 | assert response.status_code == 200 83 | assert len(response.json()['data']) == 1 84 | assert response.json()['data'][0]['uid'] == TOKENS[0]["uid"] 85 | 86 | 87 | def test_cpo_add_token_v_2_2_1(): 88 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 89 | 90 | client = TestClient(app) 91 | response = client.put(f'/ocpi/cpo/2.2.1/tokens/{TOKENS[0]["country_code"]}/{TOKENS[0]["party_id"]}/' 92 | f'{TOKENS[0]["uid"]}', json=TOKENS[0]) 93 | 94 | assert response.status_code == 200 95 | assert len(response.json()['data']) == 1 96 | assert response.json()['data'][0]['uid'] == TOKENS[0]["uid"] 97 | 98 | 99 | def test_cpo_update_token_v_2_2_1(): 100 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 101 | 102 | client = TestClient(app) 103 | response = client.patch(f'/ocpi/cpo/2.2.1/tokens/{TOKENS[0]["country_code"]}/{TOKENS[0]["party_id"]}/' 104 | f'{TOKENS[0]["uid"]}', json=TOKEN_UPDATE) 105 | 106 | assert response.status_code == 200 107 | assert len(response.json()['data']) == 1 108 | assert response.json()['data'][0]['country_code'] == TOKEN_UPDATE['country_code'] 109 | 110 | 111 | def test_emsp_get_tokens_v_2_2_1(): 112 | 113 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 114 | 115 | client = TestClient(app) 116 | response = client.get('/ocpi/emsp/2.2.1/tokens') 117 | 118 | assert response.status_code == 200 119 | assert len(response.json()['data']) == 1 120 | assert response.json()['data'][0]['uid'] == TOKENS[0]["uid"] 121 | 122 | 123 | def test_emsp_authorize_token_success_v_2_2_1(): 124 | 125 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 126 | 127 | client = TestClient(app) 128 | response = client.post(f'/ocpi/emsp/2.2.1/tokens/{TOKENS[0]["uid"]}/authorize') 129 | 130 | assert response.status_code == 200 131 | assert len(response.json()['data']) == 1 132 | assert response.json()['data'][0]['allowed'] == AllowedType.allowed 133 | 134 | 135 | def test_emsp_authorize_token_unknown_v_2_2_1(): 136 | 137 | @classmethod 138 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> Token: 139 | raise NotFoundOCPIError() 140 | _get = Crud.get 141 | Crud.get = get 142 | 143 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 144 | 145 | client = TestClient(app) 146 | response = client.post(f'/ocpi/emsp/2.2.1/tokens/{TOKENS[0]["uid"]}/authorize') 147 | 148 | assert response.status_code == 404 149 | assert response.json()['status_code'] == 2004 150 | 151 | # revert Crud changes 152 | Crud.get = _get 153 | 154 | 155 | def test_emsp_authorize_token_missing_info_v_2_2_1(): 156 | 157 | @classmethod 158 | async def do(cls, module: enums.ModuleID, role: enums.RoleEnum, action: enums.Action, *args, 159 | data: dict = None, **kwargs): 160 | return False 161 | _do = Crud.do 162 | Crud.do = do 163 | 164 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 165 | 166 | client = TestClient(app) 167 | response = client.post(f'/ocpi/emsp/2.2.1/tokens/{TOKENS[0]["uid"]}/authorize') 168 | 169 | assert response.status_code == 200 170 | assert response.json()['status_code'] == 2002 171 | 172 | # revert Crud changes 173 | Crud.do = _do 174 | -------------------------------------------------------------------------------- /tests/test_modules/test_commands.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from uuid import uuid4 3 | 4 | from fastapi.testclient import TestClient 5 | 6 | from py_ocpi import get_application 7 | from py_ocpi.core import enums 8 | from py_ocpi.core.exceptions import NotFoundOCPIError 9 | from py_ocpi.modules.tokens.v_2_2_1.enums import TokenType, WhitelistType 10 | from py_ocpi.modules.commands.v_2_2_1.enums import CommandType, CommandResponseType, CommandResultType 11 | from py_ocpi.modules.commands.v_2_2_1.schemas import CommandResponse, CommandResult 12 | from py_ocpi.modules.versions.enums import VersionNumber 13 | 14 | COMMAND_RESPONSE = { 15 | 'result': CommandResponseType.accepted, 16 | 'timeout': 30 17 | } 18 | 19 | COMMAND_RESULT = { 20 | 'result': CommandResultType.accepted, 21 | } 22 | 23 | 24 | class Crud: 25 | 26 | @classmethod 27 | async def do(cls, module: enums.ModuleID, role: enums.RoleEnum, action: enums.Action, 28 | *args, data: dict = None, **kwargs) -> dict: 29 | if action == enums.Action.get_client_token: 30 | return 'foo' 31 | 32 | return COMMAND_RESPONSE 33 | 34 | @classmethod 35 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs) -> dict: 36 | return COMMAND_RESULT 37 | 38 | @classmethod 39 | async def update(cls, module: enums.ModuleID, role: enums.RoleEnum, data: dict, id, *args, **kwargs): 40 | ... 41 | 42 | 43 | class Adapter: 44 | @classmethod 45 | def command_response_adapter(cls, data, version: VersionNumber = VersionNumber.latest): 46 | return CommandResponse(**data) 47 | 48 | @classmethod 49 | def command_result_adapter(cls, data, version: VersionNumber = VersionNumber.latest): 50 | return CommandResult(**data) 51 | 52 | 53 | def test_cpo_receive_command_start_session_v_2_2_1(): 54 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 55 | 56 | data = { 57 | 'response_url': 'https://dummy.restapiexample.com/api/v1/create', 58 | 'token': { 59 | 'country_code': 'us', 60 | 'party_id': 'AAA', 61 | 'uid': str(uuid4()), 62 | 'type': TokenType.rfid, 63 | 'contract_id': str(uuid4()), 64 | 'issuer': 'company', 65 | 'valid': True, 66 | 'whitelist': WhitelistType.always, 67 | 'last_updated': '2022-01-02 00:00:00+00:00' 68 | 69 | }, 70 | 'location_id': str(uuid4()) 71 | } 72 | 73 | client = TestClient(app) 74 | response = client.post(f'/ocpi/cpo/2.2.1/commands/{CommandType.start_session.value}', json=data) 75 | 76 | assert response.status_code == 200 77 | assert len(response.json()['data']) == 1 78 | assert response.json()['data'][0]['result'] == COMMAND_RESPONSE["result"] 79 | 80 | 81 | def test_cpo_receive_command_stop_session_v_2_2_1(): 82 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 83 | 84 | data = { 85 | 'response_url': 'https://dummy.restapiexample.com/api/v1/create', 86 | 'session_id': str(uuid4()) 87 | } 88 | 89 | client = TestClient(app) 90 | response = client.post(f'/ocpi/cpo/2.2.1/commands/{CommandType.stop_session.value}', json=data) 91 | 92 | assert response.status_code == 200 93 | assert len(response.json()['data']) == 1 94 | assert response.json()['data'][0]['result'] == COMMAND_RESPONSE["result"] 95 | 96 | 97 | def test_cpo_receive_command_reserve_now_v_2_2_1(): 98 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 99 | 100 | data = { 101 | 'response_url': 'https://dummy.restapiexample.com/api/v1/create', 102 | 'token': { 103 | 'country_code': 'us', 104 | 'party_id': 'AAA', 105 | 'uid': str(uuid4()), 106 | 'type': TokenType.rfid, 107 | 'contract_id': str(uuid4()), 108 | 'issuer': 'company', 109 | 'valid': True, 110 | 'whitelist': WhitelistType.always, 111 | 'last_updated': '2022-01-02 00:00:00+00:00' 112 | 113 | }, 114 | 'expiry_date': str(datetime.datetime.now() + datetime.timedelta(days=1)), 115 | 'reservation_id': str(uuid4()), 116 | 'location_id': str(uuid4()) 117 | } 118 | 119 | client = TestClient(app) 120 | response = client.post(f'/ocpi/cpo/2.2.1/commands/{CommandType.reserve_now.value}', json=data) 121 | 122 | assert response.status_code == 200 123 | assert len(response.json()['data']) == 1 124 | assert response.json()['data'][0]['result'] == COMMAND_RESPONSE["result"] 125 | 126 | 127 | def test_cpo_receive_command_reserve_now_unknown_location_v_2_2_1(): 128 | 129 | @classmethod 130 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs) -> dict: 131 | if module == enums.ModuleID.commands: 132 | return COMMAND_RESULT 133 | if module == enums.ModuleID.locations: 134 | raise NotFoundOCPIError() 135 | _get = Crud.get 136 | Crud.get = get 137 | 138 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.cpo], Crud, Adapter) 139 | 140 | data = { 141 | 'response_url': 'https://dummy.restapiexample.com/api/v1/create', 142 | 'token': { 143 | 'country_code': 'us', 144 | 'party_id': 'AAA', 145 | 'uid': str(uuid4()), 146 | 'type': TokenType.rfid, 147 | 'contract_id': str(uuid4()), 148 | 'issuer': 'company', 149 | 'valid': True, 150 | 'whitelist': WhitelistType.always, 151 | 'last_updated': '2022-01-02 00:00:00+00:00' 152 | 153 | }, 154 | 'expiry_date': str(datetime.datetime.now() + datetime.timedelta(days=1)), 155 | 'reservation_id': str(uuid4()), 156 | 'location_id': str(uuid4()) 157 | } 158 | 159 | client = TestClient(app) 160 | response = client.post(f'/ocpi/cpo/2.2.1/commands/{CommandType.reserve_now.value}', json=data) 161 | 162 | assert response.status_code == 200 163 | assert response.json()['data'][0]['result'] == CommandResultType.rejected 164 | 165 | # revert Crud changes 166 | Crud.get = _get 167 | 168 | 169 | def test_emsp_receive_command_result_v_2_2_1(): 170 | app = get_application(VersionNumber.v_2_2_1, [enums.RoleEnum.emsp], Crud, Adapter) 171 | 172 | client = TestClient(app) 173 | response = client.post('/ocpi/emsp/2.2.1/commands/1234', json=COMMAND_RESPONSE) 174 | 175 | assert response.status_code == 200 176 | -------------------------------------------------------------------------------- /py_ocpi/core/data_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | OCPI data types based on https://github.com/ocpi/ocpi/blob/2.2.1/types.asciidoc 3 | """ 4 | 5 | from datetime import datetime 6 | from typing import Type 7 | 8 | from pydantic.fields import ModelField 9 | 10 | 11 | class StringBase(str): 12 | """ 13 | Case sensitive String. Only printable UTF-8 allowed. 14 | (Non-printable characters like: Carriage returns, Tabs, Line breaks, etc are not allowed) 15 | """ 16 | max_length: int 17 | 18 | @classmethod 19 | def __get_validators__(cls): 20 | yield cls.validate 21 | 22 | @classmethod 23 | def __modify_schema__(cls, field_schema): 24 | field_schema.update( 25 | examples=['String'], 26 | ) 27 | 28 | @classmethod 29 | def validate(cls, v, field: ModelField): 30 | if not isinstance(v, str): 31 | raise TypeError(f'excpected string but received {type(v)}') 32 | try: 33 | v.encode('UTF-8') 34 | except UnicodeError as e: 35 | raise ValueError('invalid string format') from e 36 | if len(v) > cls.max_length: 37 | raise ValueError(f'{field.name} length must be lower or equal to {cls.max_length}') 38 | return cls(v) 39 | 40 | def __repr__(self): 41 | return f'String({super().__repr__()})' 42 | 43 | 44 | class String: 45 | def __new__(cls, max_length: int = 255) -> Type[str]: 46 | return type('String', (StringBase,), {'max_length': max_length}) 47 | 48 | 49 | class CiStringBase(str): 50 | """ 51 | Case Insensitive String. Only printable ASCII allowed. 52 | (Non-printable characters like: Carriage returns, Tabs, Line breaks, etc are not allowed) 53 | """ 54 | max_length: int 55 | 56 | @classmethod 57 | def __get_validators__(cls): 58 | yield cls.validate 59 | 60 | @classmethod 61 | def __modify_schema__(cls, field_schema): 62 | field_schema.update( 63 | examples=['string'], 64 | ) 65 | 66 | @classmethod 67 | def validate(cls, v, field: ModelField): 68 | if not isinstance(v, str): 69 | raise TypeError(f'excpected string but received {type(v)}') 70 | if not v.isascii(): 71 | raise ValueError('invalid cistring format') 72 | if len(v) > cls.max_length: 73 | raise ValueError(f'{field.name} length must be lower or equal to {cls.max_length}') 74 | return cls(v.lower()) 75 | 76 | def __repr__(self): 77 | return f'CiString({super().__repr__()})' 78 | 79 | 80 | class CiString: 81 | def __new__(cls, max_length: int = 255) -> Type[str]: 82 | return type('CiString', (CiStringBase,), {'max_length': max_length}) 83 | 84 | 85 | class URL(str): 86 | """ 87 | An URL a String(255) type following the http://www.w3.org/Addressing/URL/uri-spec.html 88 | """ 89 | @classmethod 90 | def __get_validators__(cls): 91 | yield cls.validate 92 | 93 | @classmethod 94 | def __modify_schema__(cls, field_schema): 95 | field_schema.update( 96 | examples=['http://www.w3.org/Addressing/URL/uri-spec.html'], 97 | ) 98 | 99 | @classmethod 100 | def validate(cls, v, field: ModelField): 101 | v = String(255).validate(v, field) 102 | return cls(v) 103 | 104 | def __repr__(self): 105 | return f'URL({super().__repr__()})' 106 | 107 | 108 | class DateTime(str): 109 | """ 110 | All timestamps are formatted as string(25) following RFC 3339, with some additional limitations. 111 | All timestamps SHALL be in UTC. 112 | The absence of the timezone designator implies a UTC timestamp. 113 | Fractional seconds MAY be used. 114 | """ 115 | @classmethod 116 | def __get_validators__(cls): 117 | yield cls.validate 118 | 119 | @classmethod 120 | def __modify_schema__(cls, field_schema): 121 | field_schema.update( 122 | examples=[str(datetime.now())], 123 | ) 124 | 125 | @classmethod 126 | def validate(cls, v): 127 | formated_v = datetime.fromisoformat(v) 128 | return cls(formated_v) 129 | 130 | def __repr__(self): 131 | return f'DateTime({super().__repr__()})' 132 | 133 | 134 | class DisplayText(dict): 135 | @classmethod 136 | def __get_validators__(cls): 137 | yield cls.validate 138 | 139 | @classmethod 140 | def __modify_schema__(cls, field_schema): 141 | field_schema.update( 142 | examples=[ 143 | { 144 | "language": "en", 145 | "text": "Standard Tariff" 146 | } 147 | ], 148 | ) 149 | 150 | @classmethod 151 | def validate(cls, v): 152 | if not isinstance(v, dict): 153 | raise TypeError(f'excpected dict but received {type(v)}') 154 | if 'language' not in v: 155 | raise TypeError('property "language" required') 156 | if 'text' not in v: 157 | raise TypeError('property "text" required') 158 | if len(v['text']) > 512: 159 | raise TypeError('text too long') 160 | return cls(v) 161 | 162 | def __repr__(self): 163 | return f'DateTime({super().__repr__()})' 164 | 165 | 166 | class Number(float): 167 | @classmethod 168 | def __get_validators__(cls): 169 | yield cls.validate 170 | 171 | @classmethod 172 | def __modify_schema__(cls, field_schema): 173 | field_schema.update( 174 | examples=[], 175 | ) 176 | 177 | @classmethod 178 | def validate(cls, v): 179 | if not any([isinstance(v, float), isinstance(v, int)]): 180 | TypeError(f'excpected float but received {type(v)}') 181 | return cls(float(v)) 182 | 183 | def __repr__(self): 184 | return f'Number({super().__repr__()})' 185 | 186 | 187 | class Price(dict): 188 | @classmethod 189 | def __get_validators__(cls): 190 | yield cls.validate 191 | 192 | @classmethod 193 | def __modify_schema__(cls, field_schema): 194 | field_schema.update( 195 | examples=[ 196 | { 197 | 'excl_vat': 1.0000, 198 | 'incl_vat': 1.2500 199 | } 200 | ], 201 | ) 202 | 203 | @classmethod 204 | def validate(cls, v): 205 | if not isinstance(v, dict): 206 | raise TypeError('dictionary required') 207 | if 'excl_vat' not in v: 208 | raise TypeError('property "excl_vat" required') 209 | if 'incl_vat' not in v: 210 | raise TypeError('property "incl_vat" required') 211 | return cls(v) 212 | 213 | def __repr__(self): 214 | return f'Price({super().__repr__()})' 215 | -------------------------------------------------------------------------------- /examples/v_2_2_1.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from py_ocpi import get_application 4 | from py_ocpi.core import enums 5 | from py_ocpi.modules.versions.enums import VersionNumber 6 | from py_ocpi.modules.locations.v_2_2_1.schemas import Location 7 | 8 | LOCATIONS = [ 9 | { 10 | 'country_code': 'us', 11 | 'party_id': 'AAA', 12 | 'id': str(uuid4()), 13 | 'publish': True, 14 | 'publish_allowed_to': [ 15 | { 16 | 'uid': str(uuid4()), 17 | 'type': 'APP_USER', 18 | 'visual_number': '1', 19 | 'issuer': 'issuer', 20 | 'group_id': 'group_id', 21 | }, 22 | ], 23 | 'name': 'name', 24 | 'address': 'address', 25 | 'city': 'city', 26 | 'postal_code': '111111', 27 | 'state': 'state', 28 | 'country': 'USA', 29 | 'coordinates': { 30 | 'latitude': 'latitude', 31 | 'longitude': 'longitude', 32 | }, 33 | 'related_locations': [ 34 | { 35 | 'latitude': 'latitude', 36 | 'longitude': 'longitude', 37 | 'name': { 38 | 'language': 'en', 39 | 'text': 'name' 40 | } 41 | }, 42 | ], 43 | 'parking_type': 'ON_STREET', 44 | 'evses': [ 45 | { 46 | 'uid': str(uuid4()), 47 | 'evse_id': str(uuid4()), 48 | 'status': 'AVAILABLE', 49 | 'status_schedule': { 50 | 'period_begin': '2022-01-01T00:00:00+00:00', 51 | 'period_end': '2022-01-01T00:00:00+00:00', 52 | 'status': 'AVAILABLE' 53 | }, 54 | 'capabilities': [ 55 | 'CREDIT_CARD_PAYABLE', 56 | ], 57 | 'connectors': [ 58 | { 59 | 'id': str(uuid4()), 60 | 'standard': 'DOMESTIC_A', 61 | 'format': 'SOCKET', 62 | 'power_type': 'DC', 63 | 'max_voltage': 100, 64 | 'max_amperage': 100, 65 | 'max_electric_power': 100, 66 | 'tariff_ids': [str(uuid4()), ], 67 | 'terms_and_conditions': 'https://www.example.com', 68 | 'last_updated': '2022-01-01T00:00:00+00:00', 69 | } 70 | ], 71 | 'floor_level': '3', 72 | 'coordinates': { 73 | 'latitude': 'latitude', 74 | 'longitude': 'longitude', 75 | }, 76 | 'physical_reference': 'pr', 77 | 'directions': [ 78 | { 79 | 'language': 'en', 80 | 'text': 'directions' 81 | }, 82 | ], 83 | 'parking_restrictions': ['EV_ONLY', ], 84 | 'images': [ 85 | { 86 | 'url': 'https://www.example.com', 87 | 'thumbnail': 'https://www.example.com', 88 | 'category': 'CHARGER', 89 | 'type': 'type', 90 | 'width': 10, 91 | 'height': 10 92 | }, 93 | ], 94 | 'last_updated': '2022-01-01T00:00:00+00:00' 95 | } 96 | ], 97 | 'directions': [ 98 | { 99 | 'language': 'en', 100 | 'text': 'directions' 101 | }, 102 | ], 103 | 'operator': { 104 | 'name': 'name', 105 | 'website': 'https://www.example.com', 106 | 'logo': { 107 | 'url': 'https://www.example.com', 108 | 'thumbnail': 'https://www.example.com', 109 | 'category': 'CHARGER', 110 | 'type': 'type', 111 | 'width': 10, 112 | 'height': 10 113 | } 114 | }, 115 | 'suboperator': { 116 | 'name': 'name', 117 | 'website': 'https://www.example.com', 118 | 'logo': { 119 | 'url': 'https://www.example.com', 120 | 'thumbnail': 'https://www.example.com', 121 | 'category': 'CHARGER', 122 | 'type': 'type', 123 | 'width': 10, 124 | 'height': 10 125 | } 126 | }, 127 | 'owner': { 128 | 'name': 'name', 129 | 'website': 'https://www.example.com', 130 | 'logo': { 131 | 'url': 'https://www.example.com', 132 | 'thumbnail': 'https://www.example.com', 133 | 'category': 'CHARGER', 134 | 'type': 'type', 135 | 'width': 10, 136 | 'height': 10 137 | } 138 | }, 139 | 'facilities': ['MALL'], 140 | 'time_zone': 'UTC+2', 141 | 'opening_times': { 142 | 'twentyfourseven': True, 143 | 'regular_hours': [ 144 | { 145 | 'weekday': 1, 146 | 'period_begin': '8:00', 147 | 'period_end': '22:00', 148 | }, 149 | { 150 | 'weekday': 2, 151 | 'period_begin': '8:00', 152 | 'period_end': '22:00', 153 | }, 154 | ], 155 | 'exceptional_openings': [ 156 | { 157 | 'period_begin': '2022-01-01T00:00:00+00:00', 158 | 'period_end': '2022-01-02T00:00:00+00:00', 159 | }, 160 | ], 161 | 'exceptional_closings': [], 162 | }, 163 | 'charging_when_closed': False, 164 | 'images': [ 165 | { 166 | 'url': 'https://www.example.com', 167 | 'thumbnail': 'https://www.example.com', 168 | 'category': 'CHARGER', 169 | 'type': 'type', 170 | 'width': 10, 171 | 'height': 10 172 | }, 173 | ], 174 | 'energy_mix': { 175 | 'is_green_energy': True, 176 | 'energy_sources': [ 177 | { 178 | 'source': 'SOLAR', 179 | 'percentage': 100 180 | }, 181 | ], 182 | 'supplier_name': 'supplier_name', 183 | 'energy_product_name': 'energy_product_name' 184 | }, 185 | 'last_updated': '2022-01-02 00:00:00+00:00', 186 | } 187 | ] 188 | 189 | 190 | class Crud: 191 | @classmethod 192 | async def get(cls, module: enums.ModuleID, role: enums.RoleEnum, id, *args, **kwargs): 193 | return LOCATIONS[0] 194 | 195 | @classmethod 196 | async def list(cls, module: enums.ModuleID, role: enums.RoleEnum, filters: dict, *args, **kwargs) -> list: 197 | return LOCATIONS, 1, True 198 | 199 | 200 | class Adapter: 201 | @classmethod 202 | def location_adapter(cls, data, version: VersionNumber) -> Location: 203 | return Location(**data) 204 | 205 | 206 | app = get_application([VersionNumber.v_2_2_1], [enums.RoleEnum.cpo], Crud, Adapter) 207 | -------------------------------------------------------------------------------- /py_ocpi/modules/credentials/v_2_2_1/api/cpo.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Request, status as fastapistatus 4 | 5 | from py_ocpi.core.schemas import OCPIResponse 6 | from py_ocpi.core.adapter import Adapter 7 | from py_ocpi.core.crud import Crud 8 | from py_ocpi.core.utils import encode_string_base64, get_auth_token 9 | from py_ocpi.core.dependencies import get_crud, get_adapter 10 | from py_ocpi.core import status 11 | from py_ocpi.core.enums import Action, ModuleID, RoleEnum 12 | from py_ocpi.modules.versions.enums import VersionNumber 13 | from py_ocpi.modules.credentials.v_2_2_1.schemas import Credentials 14 | 15 | router = APIRouter( 16 | prefix='/credentials', 17 | ) 18 | 19 | 20 | @router.get("/", response_model=OCPIResponse) 21 | async def get_credentials(request: Request, crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 22 | auth_token = get_auth_token(request) 23 | 24 | data = await crud.get(ModuleID.credentials_and_registration, RoleEnum.cpo, 25 | auth_token, auth_token=auth_token, version=VersionNumber.v_2_2_1) 26 | return OCPIResponse( 27 | data=adapter.credentials_adapter(data).dict(), 28 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 29 | ) 30 | 31 | 32 | @router.post("/", response_model=OCPIResponse) 33 | async def post_credentials(request: Request, credentials: Credentials, 34 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 35 | auth_token = get_auth_token(request) 36 | 37 | # Check if the client is already registered 38 | credentials_client_token = credentials.token 39 | server_cred = await crud.do(ModuleID.credentials_and_registration, RoleEnum.cpo, Action.get_client_token, 40 | version=VersionNumber.v_2_2_1, auth_token=auth_token) 41 | if server_cred: 42 | raise HTTPException(fastapistatus.HTTP_405_METHOD_NOT_ALLOWED, "Client is already registered") 43 | 44 | # Retrieve the versions and endpoints from the client 45 | async with httpx.AsyncClient() as client: 46 | authorization_token = f'Token {encode_string_base64(credentials_client_token)}' 47 | response_versions = await client.get(credentials.url, 48 | headers={'authorization': authorization_token}) 49 | 50 | if response_versions.status_code == fastapistatus.HTTP_200_OK: 51 | version_url = None 52 | versions = response_versions.json()['data'] 53 | 54 | for version in versions: 55 | if version['version'] == VersionNumber.v_2_2_1: 56 | version_url = version['url'] 57 | 58 | if not version_url: 59 | return OCPIResponse( 60 | data=[], 61 | **status.OCPI_3002_UNSUPPORTED_VERSION, 62 | ) 63 | 64 | response_endpoints = await client.get(version_url, 65 | headers={'authorization': authorization_token}) 66 | 67 | if response_endpoints.status_code == fastapistatus.HTTP_200_OK: 68 | # Store client credentials and generate new credentials for sender 69 | endpoints = response_endpoints.json()['data'] 70 | new_credentials = await crud.create( 71 | ModuleID.credentials_and_registration, RoleEnum.cpo, 72 | { 73 | "credentials": credentials.dict(), 74 | "endpoints": endpoints 75 | }, 76 | auth_token=auth_token, 77 | version=VersionNumber.v_2_2_1 78 | ) 79 | 80 | return OCPIResponse( 81 | data=adapter.credentials_adapter(new_credentials).dict(), 82 | **status.OCPI_1000_GENERIC_SUCESS_CODE 83 | ) 84 | 85 | return OCPIResponse( 86 | data=[], 87 | **status.OCPI_3001_UNABLE_TO_USE_CLIENTS_API, 88 | ) 89 | 90 | 91 | @router.put("/", response_model=OCPIResponse) 92 | async def update_credentials(request: Request, credentials: Credentials, 93 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 94 | auth_token = get_auth_token(request) 95 | 96 | # Check if the client is already registered 97 | credentials_client_token = credentials.token 98 | server_cred = await crud.do(ModuleID.credentials_and_registration, RoleEnum.cpo, Action.get_client_token, 99 | version=VersionNumber.v_2_2_1, auth_token=auth_token) 100 | if not server_cred: 101 | raise HTTPException(fastapistatus.HTTP_405_METHOD_NOT_ALLOWED, "Client is not registered") 102 | 103 | # Retrieve the versions and endpoints from the client 104 | async with httpx.AsyncClient() as client: 105 | authorization_token = f'Token {encode_string_base64(credentials_client_token)}' 106 | response_versions = await client.get(credentials.url, headers={'authorization': authorization_token}) 107 | 108 | if response_versions.status_code == fastapistatus.HTTP_200_OK: 109 | version_url = None 110 | versions = response_versions.json()['data'] 111 | 112 | for version in versions: 113 | if version['version'] == VersionNumber.v_2_2_1: 114 | version_url = version['url'] 115 | 116 | if not version_url: 117 | return OCPIResponse( 118 | data=[], 119 | **status.OCPI_3002_UNSUPPORTED_VERSION, 120 | ) 121 | 122 | response_endpoints = await client.get(version_url, 123 | headers={'authorization': authorization_token}) 124 | 125 | if response_endpoints.status_code == fastapistatus.HTTP_200_OK: 126 | # Update server credentials to access client's system and generate new credentials token 127 | endpoints = response_endpoints.json()['data'][0] 128 | new_credentials = await crud.update(ModuleID.credentials_and_registration, RoleEnum.cpo, 129 | { 130 | "credentials": credentials.dict(), 131 | "endpoints": endpoints 132 | }, 133 | auth_token=auth_token, 134 | version=VersionNumber.v_2_2_1) 135 | 136 | return OCPIResponse( 137 | data=adapter.credentials_adapter(new_credentials).dict(), 138 | **status.OCPI_1000_GENERIC_SUCESS_CODE 139 | ) 140 | 141 | return OCPIResponse( 142 | data=[], 143 | **status.OCPI_3001_UNABLE_TO_USE_CLIENTS_API, 144 | ) 145 | 146 | 147 | @router.delete("/", response_model=OCPIResponse) 148 | async def remove_credentials(request: Request, crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 149 | auth_token = get_auth_token(request) 150 | 151 | data = await crud.get(ModuleID.credentials_and_registration, RoleEnum.cpo, 152 | auth_token, auth_token=auth_token, version=VersionNumber.v_2_2_1) 153 | if not data: 154 | raise HTTPException(fastapistatus.HTTP_405_METHOD_NOT_ALLOWED, "Client is not registered") 155 | 156 | await crud.delete(ModuleID.credentials_and_registration, RoleEnum.cpo, 157 | auth_token, auth_token=auth_token, version=VersionNumber.v_2_2_1) 158 | 159 | return OCPIResponse( 160 | data=[], 161 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 162 | ) 163 | -------------------------------------------------------------------------------- /py_ocpi/modules/credentials/v_2_2_1/api/emsp.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Request, status as fastapistatus 4 | 5 | from py_ocpi.core.schemas import OCPIResponse 6 | from py_ocpi.core.adapter import Adapter 7 | from py_ocpi.core.crud import Crud 8 | from py_ocpi.core.utils import encode_string_base64, get_auth_token 9 | from py_ocpi.core.dependencies import get_crud, get_adapter 10 | from py_ocpi.core import status 11 | from py_ocpi.core.enums import Action, ModuleID, RoleEnum 12 | from py_ocpi.modules.versions.enums import VersionNumber 13 | from py_ocpi.modules.credentials.v_2_2_1.schemas import Credentials 14 | 15 | router = APIRouter( 16 | prefix='/credentials', 17 | ) 18 | 19 | 20 | @router.get("/", response_model=OCPIResponse) 21 | async def get_credentials(request: Request, crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 22 | auth_token = get_auth_token(request) 23 | 24 | data = await crud.get(ModuleID.credentials_and_registration, RoleEnum.emsp, 25 | auth_token, auth_token=auth_token, version=VersionNumber.v_2_2_1) 26 | return OCPIResponse( 27 | data=adapter.credentials_adapter(data).dict(), 28 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 29 | ) 30 | 31 | 32 | @router.post("/", response_model=OCPIResponse) 33 | async def post_credentials(request: Request, credentials: Credentials, 34 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 35 | auth_token = get_auth_token(request) 36 | 37 | # Check if the client is already registered 38 | credentials_client_token = credentials.token 39 | server_cred = await crud.do(ModuleID.credentials_and_registration, RoleEnum.emsp, Action.get_client_token, 40 | version=VersionNumber.v_2_2_1, auth_token=auth_token) 41 | if server_cred: 42 | raise HTTPException(fastapistatus.HTTP_405_METHOD_NOT_ALLOWED, "Client is already registered") 43 | 44 | # Retrieve the versions and endpoints from the client 45 | async with httpx.AsyncClient() as client: 46 | authorization_token = f'Token {encode_string_base64(credentials_client_token)}' 47 | response_versions = await client.get(credentials.url, 48 | headers={'authorization': authorization_token}) 49 | 50 | if response_versions.status_code == fastapistatus.HTTP_200_OK: 51 | version_url = None 52 | versions = response_versions.json()['data'] 53 | 54 | for version in versions: 55 | if version['version'] == VersionNumber.v_2_2_1: 56 | version_url = version['url'] 57 | 58 | if not version_url: 59 | return OCPIResponse( 60 | data=[], 61 | **status.OCPI_3002_UNSUPPORTED_VERSION, 62 | ) 63 | 64 | response_endpoints = await client.get(version_url, 65 | headers={'authorization': authorization_token}) 66 | 67 | if response_endpoints.status_code == fastapistatus.HTTP_200_OK: 68 | # Store client credentials and generate new credentials for sender 69 | endpoints = response_endpoints.json()['data'] 70 | new_credentials = await crud.create( 71 | ModuleID.credentials_and_registration, RoleEnum.emsp, 72 | { 73 | "credentials": credentials.dict(), 74 | "endpoints": endpoints 75 | }, 76 | auth_token=auth_token, 77 | version=VersionNumber.v_2_2_1 78 | ) 79 | 80 | return OCPIResponse( 81 | data=adapter.credentials_adapter(new_credentials).dict(), 82 | **status.OCPI_1000_GENERIC_SUCESS_CODE 83 | ) 84 | 85 | return OCPIResponse( 86 | data=[], 87 | **status.OCPI_3001_UNABLE_TO_USE_CLIENTS_API, 88 | ) 89 | 90 | 91 | @router.put("/", response_model=OCPIResponse) 92 | async def update_credentials(request: Request, credentials: Credentials, 93 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 94 | auth_token = get_auth_token(request) 95 | 96 | # Check if the client is already registered 97 | credentials_client_token = credentials.token 98 | server_cred = await crud.do(ModuleID.credentials_and_registration, RoleEnum.emsp, Action.get_client_token, 99 | version=VersionNumber.v_2_2_1, auth_token=auth_token) 100 | if not server_cred: 101 | raise HTTPException(fastapistatus.HTTP_405_METHOD_NOT_ALLOWED, "Client is not registered") 102 | 103 | # Retrieve the versions and endpoints from the client 104 | async with httpx.AsyncClient() as client: 105 | authorization_token = f'Token {encode_string_base64(credentials_client_token)}' 106 | response_versions = await client.get(credentials.url, headers={'authorization': authorization_token}) 107 | 108 | if response_versions.status_code == fastapistatus.HTTP_200_OK: 109 | version_url = None 110 | versions = response_versions.json()['data'] 111 | 112 | for version in versions: 113 | if version['version'] == VersionNumber.v_2_2_1: 114 | version_url = version['url'] 115 | 116 | if not version_url: 117 | return OCPIResponse( 118 | data=[], 119 | **status.OCPI_3002_UNSUPPORTED_VERSION, 120 | ) 121 | 122 | response_endpoints = await client.get(version_url, 123 | headers={'authorization': authorization_token}) 124 | 125 | if response_endpoints.status_code == fastapistatus.HTTP_200_OK: 126 | # Update server credentials to access client's system and generate new credentials token 127 | endpoints = response_endpoints.json()['data'][0] 128 | new_credentials = await crud.update(ModuleID.credentials_and_registration, RoleEnum.emsp, 129 | { 130 | "credentials": credentials.dict(), 131 | "endpoints": endpoints 132 | }, 133 | auth_token=auth_token, 134 | version=VersionNumber.v_2_2_1) 135 | 136 | return OCPIResponse( 137 | data=adapter.credentials_adapter(new_credentials).dict(), 138 | **status.OCPI_1000_GENERIC_SUCESS_CODE 139 | ) 140 | 141 | return OCPIResponse( 142 | data=[], 143 | **status.OCPI_3001_UNABLE_TO_USE_CLIENTS_API, 144 | ) 145 | 146 | 147 | @router.delete("/", response_model=OCPIResponse) 148 | async def remove_credentials(request: Request, 149 | crud: Crud = Depends(get_crud), adapter: Adapter = Depends(get_adapter)): 150 | auth_token = get_auth_token(request) 151 | 152 | data = await crud.get(ModuleID.credentials_and_registration, RoleEnum.emsp, 153 | auth_token, auth_token=auth_token, version=VersionNumber.v_2_2_1) 154 | if not data: 155 | raise HTTPException(fastapistatus.HTTP_405_METHOD_NOT_ALLOWED, "Client is not registered") 156 | 157 | await crud.delete(ModuleID.credentials_and_registration, RoleEnum.emsp, 158 | auth_token, auth_token=auth_token, version=VersionNumber.v_2_2_1) 159 | 160 | return OCPIResponse( 161 | data=[], 162 | **status.OCPI_1000_GENERIC_SUCESS_CODE, 163 | ) 164 | -------------------------------------------------------------------------------- /tests/test_push.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | from unittest.mock import AsyncMock, MagicMock, patch 3 | 4 | from fastapi.testclient import TestClient 5 | 6 | from py_ocpi import get_application 7 | from py_ocpi.core import enums, schemas 8 | from py_ocpi.modules.locations.v_2_2_1.schemas import Location 9 | from py_ocpi.modules.versions.enums import VersionNumber 10 | from tests.test_modules.mocks.async_client import MockAsyncClientGeneratorVersionsAndEndpoints 11 | 12 | LOCATIONS = [ 13 | { 14 | 'country_code': 'us', 15 | 'party_id': 'AAA', 16 | 'id': str(uuid4()), 17 | 'publish': True, 18 | 'publish_allowed_to': [ 19 | { 20 | 'uid': str(uuid4()), 21 | 'type': 'APP_USER', 22 | 'visual_number': '1', 23 | 'issuer': 'issuer', 24 | 'group_id': 'group_id', 25 | }, 26 | ], 27 | 'name': 'name', 28 | 'address': 'address', 29 | 'city': 'city', 30 | 'postal_code': '111111', 31 | 'state': 'state', 32 | 'country': 'USA', 33 | 'coordinates': { 34 | 'latitude': 'latitude', 35 | 'longitude': 'longitude', 36 | }, 37 | 'related_locations': [ 38 | { 39 | 'latitude': 'latitude', 40 | 'longitude': 'longitude', 41 | 'name': { 42 | 'language': 'en', 43 | 'text': 'name' 44 | } 45 | }, 46 | ], 47 | 'parking_type': 'ON_STREET', 48 | 'evses': [ 49 | { 50 | 'uid': str(uuid4()), 51 | 'evse_id': str(uuid4()), 52 | 'status': 'AVAILABLE', 53 | 'status_schedule': { 54 | 'period_begin': '2022-01-01T00:00:00+00:00', 55 | 'period_end': '2022-01-01T00:00:00+00:00', 56 | 'status': 'AVAILABLE' 57 | }, 58 | 'capabilities': [ 59 | 'CREDIT_CARD_PAYABLE', 60 | ], 61 | 'connectors': [ 62 | { 63 | 'id': str(uuid4()), 64 | 'standard': 'DOMESTIC_A', 65 | 'format': 'SOCKET', 66 | 'power_type': 'DC', 67 | 'max_voltage': 100, 68 | 'max_amperage': 100, 69 | 'max_electric_power': 100, 70 | 'tariff_ids': [str(uuid4()), ], 71 | 'terms_and_conditions': 'https://www.example.com', 72 | 'last_updated': '2022-01-01T00:00:00+00:00', 73 | } 74 | ], 75 | 'floor_level': '3', 76 | 'coordinates': { 77 | 'latitude': 'latitude', 78 | 'longitude': 'longitude', 79 | }, 80 | 'physical_reference': 'pr', 81 | 'directions': [ 82 | { 83 | 'language': 'en', 84 | 'text': 'directions' 85 | }, 86 | ], 87 | 'parking_restrictions': ['EV_ONLY', ], 88 | 'images': [ 89 | { 90 | 'url': 'https://www.example.com', 91 | 'thumbnail': 'https://www.example.com', 92 | 'category': 'CHARGER', 93 | 'type': 'type', 94 | 'width': 10, 95 | 'height': 10 96 | }, 97 | ], 98 | 'last_updated': '2022-01-01T00:00:00+00:00' 99 | } 100 | ], 101 | 'directions': [ 102 | { 103 | 'language': 'en', 104 | 'text': 'directions' 105 | }, 106 | ], 107 | 'operator': { 108 | 'name': 'name', 109 | 'website': 'https://www.example.com', 110 | 'logo': { 111 | 'url': 'https://www.example.com', 112 | 'thumbnail': 'https://www.example.com', 113 | 'category': 'CHARGER', 114 | 'type': 'type', 115 | 'width': 10, 116 | 'height': 10 117 | } 118 | }, 119 | 'suboperator': { 120 | 'name': 'name', 121 | 'website': 'https://www.example.com', 122 | 'logo': { 123 | 'url': 'https://www.example.com', 124 | 'thumbnail': 'https://www.example.com', 125 | 'category': 'CHARGER', 126 | 'type': 'type', 127 | 'width': 10, 128 | 'height': 10 129 | } 130 | }, 131 | 'owner': { 132 | 'name': 'name', 133 | 'website': 'https://www.example.com', 134 | 'logo': { 135 | 'url': 'https://www.example.com', 136 | 'thumbnail': 'https://www.example.com', 137 | 'category': 'CHARGER', 138 | 'type': 'type', 139 | 'width': 10, 140 | 'height': 10 141 | } 142 | }, 143 | 'facilities': ['MALL'], 144 | 'time_zone': 'UTC+2', 145 | 'opening_times': { 146 | 'twentyfourseven': True, 147 | 'regular_hours': [ 148 | { 149 | 'weekday': 1, 150 | 'period_begin': '8:00', 151 | 'period_end': '22:00', 152 | }, 153 | { 154 | 'weekday': 2, 155 | 'period_begin': '8:00', 156 | 'period_end': '22:00', 157 | }, 158 | ], 159 | 'exceptional_openings': [ 160 | { 161 | 'period_begin': '2022-01-01T00:00:00+00:00', 162 | 'period_end': '2022-01-02T00:00:00+00:00', 163 | }, 164 | ], 165 | 'exceptional_closings': [], 166 | }, 167 | 'charging_when_closed': False, 168 | 'images': [ 169 | { 170 | 'url': 'https://www.example.com', 171 | 'thumbnail': 'https://www.example.com', 172 | 'category': 'CHARGER', 173 | 'type': 'type', 174 | 'width': 10, 175 | 'height': 10 176 | }, 177 | ], 178 | 'energy_mix': { 179 | 'is_green_energy': True, 180 | 'energy_sources': [ 181 | { 182 | 'source': 'SOLAR', 183 | 'percentage': 100 184 | }, 185 | ], 186 | 'supplier_name': 'supplier_name', 187 | 'energy_product_name': 'energy_product_name' 188 | }, 189 | 'last_updated': '2022-01-02 00:00:00+00:00', 190 | } 191 | ] 192 | 193 | 194 | @patch('py_ocpi.core.push.httpx.AsyncClient', 195 | side_effect=MockAsyncClientGeneratorVersionsAndEndpoints) 196 | def test_push(async_client): 197 | crud = AsyncMock() 198 | adapter = MagicMock() 199 | 200 | crud.get.return_value = LOCATIONS[0] 201 | adapter.location_adapter.return_value = Location(**LOCATIONS[0]) 202 | 203 | app = get_application([VersionNumber.v_2_2_1], [enums.RoleEnum.cpo], crud, adapter, http_push=True) 204 | 205 | client = TestClient(app) 206 | data = schemas.Push( 207 | module_id=enums.ModuleID.locations, 208 | object_id='1', 209 | receivers=[ 210 | schemas.Receiver( 211 | endpoints_url='http://example.com', 212 | auth_token='token' 213 | ), 214 | ] 215 | ).dict() 216 | client.post('/push/2.2.1', json=data) 217 | 218 | crud.get.assert_awaited_once() 219 | adapter.location_adapter.assert_called_once() 220 | --------------------------------------------------------------------------------