From 7740be8bb514731cdc4e9e5753f6159b7b35d328 Mon Sep 17 00:00:00 2001 From: BluemediaGER Date: Sat, 13 Apr 2024 22:43:03 +0200 Subject: [PATCH 1/2] Reafctor app --- .gitignore | 1 - app/__init__.py | 0 {src => app}/database.py | 9 +- {src => app}/main.py | 8 +- app/models/__init__.py | 8 ++ {src => app}/models/chargepoint.py | 9 +- app/models/connector.py | 15 +++ app/models/id_token.py | 16 +++ app/models/meter_value.py | 17 +++ app/models/transaction.py | 20 +++ app/models/user.py | 14 +++ app/ocpp_proto/__init__.py | 0 app/ocpp_proto/chargepoint.py | 88 ++++++++++++++ .../ocpp_proto/chargepoint_manager.py | 17 +-- app/routers/__init__.py | 0 app/routers/chargepoint_v1.py | 114 ++++++++++++++++++ {src => app}/routers/ocpp_v1.py | 19 +-- app/schemas/__init__.py | 0 app/schemas/chargepoint.py | 31 +++++ {src => app}/schemas/connector.py | 3 +- {src => app}/schemas/id_token.py | 5 +- {src => app}/schemas/meter_value.py | 3 +- {src => app}/schemas/transaction.py | 3 +- {src => app}/schemas/user.py | 5 +- {src => app}/security.py | 0 app/util/__init__.py | 0 {src => app}/util/websocket_auth_backend.py | 19 ++- {src => app}/util/websocket_wrapper.py | 0 src/models/connector.py | 14 --- src/models/id_token.py | 14 --- src/models/meter_value.py | 16 --- src/models/transaction.py | 19 --- src/models/user.py | 12 -- src/ocpp_proto/chargepoint.py | 33 ----- src/routers/chargepoint_v1.py | 45 ------- src/schemas/chargepoint.py | 20 --- 36 files changed, 389 insertions(+), 208 deletions(-) create mode 100644 app/__init__.py rename {src => app}/database.py (81%) rename {src => app}/main.py (81%) create mode 100644 app/models/__init__.py rename {src => app}/models/chargepoint.py (54%) create mode 100644 app/models/connector.py create mode 100644 app/models/id_token.py create mode 100644 app/models/meter_value.py create mode 100644 app/models/transaction.py create mode 100644 app/models/user.py create mode 100644 app/ocpp_proto/__init__.py create mode 100644 app/ocpp_proto/chargepoint.py rename {src => app}/ocpp_proto/chargepoint_manager.py (56%) create mode 100644 app/routers/__init__.py create mode 100644 app/routers/chargepoint_v1.py rename {src => app}/routers/ocpp_v1.py (69%) create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/chargepoint.py rename {src => app}/schemas/connector.py (90%) rename {src => app}/schemas/id_token.py (77%) rename {src => app}/schemas/meter_value.py (98%) rename {src => app}/schemas/transaction.py (97%) rename {src => app}/schemas/user.py (76%) rename {src => app}/security.py (100%) create mode 100644 app/util/__init__.py rename {src => app}/util/websocket_auth_backend.py (54%) rename {src => app}/util/websocket_wrapper.py (100%) delete mode 100644 src/models/connector.py delete mode 100644 src/models/id_token.py delete mode 100644 src/models/meter_value.py delete mode 100644 src/models/transaction.py delete mode 100644 src/models/user.py delete mode 100644 src/ocpp_proto/chargepoint.py delete mode 100644 src/routers/chargepoint_v1.py delete mode 100644 src/schemas/chargepoint.py diff --git a/.gitignore b/.gitignore index 3b96008..9a5ab14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -**/__init__.py **/__pycache__ simple-ocpp-cs.db \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database.py b/app/database.py similarity index 81% rename from src/database.py rename to app/database.py index de3e000..233eeb5 100644 --- a/src/database.py +++ b/app/database.py @@ -14,4 +14,11 @@ else: SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() \ No newline at end of file +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/main.py b/app/main.py similarity index 81% rename from src/main.py rename to app/main.py index ee9aaca..b9cbb02 100644 --- a/src/main.py +++ b/app/main.py @@ -2,9 +2,11 @@ from fastapi import FastAPI from starlette.middleware.authentication import AuthenticationMiddleware import uvicorn -from routers import chargepoint_v1, ocpp_v1 -from database import engine, Base -from util.websocket_auth_backend import BasicAuthBackend +from app.database import engine, Base +from app.models import * + +from app.routers import chargepoint_v1, ocpp_v1 +from app.util.websocket_auth_backend import BasicAuthBackend Base.metadata.create_all(bind=engine) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..0f3f4ba --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,8 @@ +__all__ = [ + "chargepoint", + "connector", + "id_token", + "meter_value", + "transaction", + "user" +] \ No newline at end of file diff --git a/src/models/chargepoint.py b/app/models/chargepoint.py similarity index 54% rename from src/models/chargepoint.py rename to app/models/chargepoint.py index 8115257..3c5db63 100644 --- a/src/models/chargepoint.py +++ b/app/models/chargepoint.py @@ -1,15 +1,16 @@ -from sqlalchemy import Boolean, Column, DateTime, String +import uuid +from sqlalchemy import Uuid, Boolean, Column, DateTime, String from sqlalchemy.orm import relationship -from database import Base +from app.database import Base class ChargePoint(Base): __tablename__ = "chargepoints" - id = Column(String, primary_key=True) + id = Column(Uuid, primary_key=True, default=uuid.uuid4) friendly_name = Column(String, unique=True, index=True) is_active = Column(Boolean, default=True) password = Column(String) last_seen = Column(DateTime, nullable=True) - connectors = relationship("Connector") + connectors = relationship("Connector", cascade="delete, delete-orphan") diff --git a/app/models/connector.py b/app/models/connector.py new file mode 100644 index 0000000..97f168a --- /dev/null +++ b/app/models/connector.py @@ -0,0 +1,15 @@ +import uuid +from sqlalchemy import Uuid, Column, Enum, ForeignKey, Integer + +from app.schemas.connector import ConnectorStatus +from app.database import Base + +class Connector(Base): + __tablename__ = "connectors" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + evse = Column(Integer) + index = Column(Integer) + status = Column(Enum(ConnectorStatus)) + + chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id")) diff --git a/app/models/id_token.py b/app/models/id_token.py new file mode 100644 index 0000000..61651b2 --- /dev/null +++ b/app/models/id_token.py @@ -0,0 +1,16 @@ +import uuid +from sqlalchemy import Uuid, Boolean, Column, ForeignKey, String +from sqlalchemy.orm import relationship + +from app.database import Base + +class IdToken(Base): + __tablename__ = "id_tokens" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + title = Column(String) + is_active = Column(Boolean, default=True) + token = Column(String, index=True) + + owner_id = Column(Uuid, ForeignKey("users.id")) + owner = relationship("User", back_populates="id_tokens") diff --git a/app/models/meter_value.py b/app/models/meter_value.py new file mode 100644 index 0000000..9cd0835 --- /dev/null +++ b/app/models/meter_value.py @@ -0,0 +1,17 @@ +import uuid +from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String + +from app.database import Base +from app.schemas.meter_value import Measurand, PhaseType + +class Transaction(Base): + __tablename__ = "meter_values" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + timestamp = Column(DateTime, index=True) + measurand = Column(Enum(Measurand)) + phase_type = Column(Enum(PhaseType)) + unit = Column(String) + value = Column(Float) + + transaction_id = Column(Uuid, ForeignKey("transactions.id"), index=True) \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..18cc83c --- /dev/null +++ b/app/models/transaction.py @@ -0,0 +1,20 @@ +import uuid +from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey +from sqlalchemy.orm import relationship, backref + +from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus +from app.database import Base + +class Transaction(Base): + __tablename__ = "transactions" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + status = Column(Enum(TransactionStatus), index=True) + started_at = Column(DateTime, index=True) + ended_at = Column(DateTime, nullable=True, index=True) + meter_start = Column(Float) + meter_end = Column(Float, nullable=True) + end_reason = Column(Enum(TransactionEventTriggerReason)) + + connector_id = Column(Uuid, ForeignKey("connectors.id"), index=True) + id_token_id = Column(Uuid, ForeignKey("id_tokens.id"), index= True) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..339e5f7 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,14 @@ +import uuid +from sqlalchemy import Uuid, Boolean, Column, String +from sqlalchemy.orm import relationship + +from app.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + friendly_name = Column(String, unique=True, index=True) + is_active = Column(Boolean, default=True) + + id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan") diff --git a/app/ocpp_proto/__init__.py b/app/ocpp_proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ocpp_proto/chargepoint.py b/app/ocpp_proto/chargepoint.py new file mode 100644 index 0000000..d2accb2 --- /dev/null +++ b/app/ocpp_proto/chargepoint.py @@ -0,0 +1,88 @@ +from datetime import datetime, UTC +import os + +from ocpp.routing import on +from ocpp.v201 import ChargePoint as cp +from ocpp.v201 import call_result +from ocpp.v201.enums import Action, RegistrationStatusType, AuthorizationStatusType + +from app.database import SessionLocal +from app.models.chargepoint import ChargePoint +from app.models.connector import Connector +from app.models.id_token import IdToken +from app.schemas.connector import ConnectorStatus + +class ChargePoint(cp): + + @on(Action.BootNotification) + async def on_boot_notification(self, charging_station, reason, **kwargs): + with SessionLocal() as db: + db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first() + db_chargepoint.last_seen = datetime.now(UTC) + db.commit() + return call_result.BootNotificationPayload( + current_time=datetime.now(UTC).isoformat(), + interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")), + status=RegistrationStatusType.accepted + ) + + @on(Action.Heartbeat) + async def on_heartbeat_request(self): + with SessionLocal() as db: + db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first() + db_chargepoint.last_seen = datetime.now(UTC) + db.commit() + return call_result.HeartbeatPayload( + current_time=datetime.now(UTC).isoformat() + ) + + @on(Action.StatusNotification) + async def on_status_notification(self, evse_id: int, connector_id: int, connector_status: str, **kwargs): + with SessionLocal() as db: + db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first() + db_chargepoint.last_seen = datetime.now(UTC) + + db_connector = db.query(Connector).filter( + Connector.chargepoint_id == db_chargepoint.id, + Connector.evse == evse_id, + Connector.index == connector_id + ).first() + if db_connector == None: + db_connector = Connector( + chargepoint_id = db_chargepoint.id, + evse = evse_id, + index = connector_id, + status = ConnectorStatus(connector_status) + ) + db.add(db_connector) + else: + db_connector.status = ConnectorStatus(connector_status) + + db.commit() + + return call_result.StatusNotificationPayload() + + @on(Action.Authorize) + async def on_authorize(self, id_token, **kwargs): + if id_token == None: + return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.invalid}) + if id_token.type != "ISO14443" | "ISO15693": + return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.invalid}) + + with SessionLocal() as db: + db_chargepoint = db.query(ChargePoint).filter(ChargePoint.friendly_name == self.id).first() + db_chargepoint.last_seen = datetime.now(UTC) + + db_id_token = db.query(IdToken).filter(IdToken.token == id_token.id).first() + db.commit() + + if db_id_token == None: + return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.unknown}) + if db_id_token.is_active == False: + return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.blocked}) + + return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.accepted, 'groupIdToken': str(db_id_token.owner_id)}) + + @on(Action.TransactionEvent) + async def on_transaction_event(self): + return call_result.TransactionEventPayload() diff --git a/src/ocpp_proto/chargepoint_manager.py b/app/ocpp_proto/chargepoint_manager.py similarity index 56% rename from src/ocpp_proto/chargepoint_manager.py rename to app/ocpp_proto/chargepoint_manager.py index 5035adb..60ebdc6 100644 --- a/src/ocpp_proto/chargepoint_manager.py +++ b/app/ocpp_proto/chargepoint_manager.py @@ -1,22 +1,23 @@ import logging from typing import Any, Coroutine, Dict +from uuid import UUID from websockets import ConnectionClosed -from ocpp_proto.chargepoint import ChargePoint +from app.ocpp_proto.chargepoint import ChargePoint -__active_connections: Dict[str, ChargePoint] = {} +__active_connections: Dict[UUID, ChargePoint] = {} -async def start(cp: ChargePoint): +async def start(id: UUID, cp: ChargePoint): try: - __active_connections[cp.id] = cp + __active_connections[id] = cp await cp.start() except ConnectionClosed: - logging.info("Charging station '%s' disconnected", cp.id) - __active_connections.pop(cp.id, None) + logging.info("Charging station '%s' (%s) disconnected", cp.id, id) + __active_connections.pop(id, None) async def call( - chargepoint_id: str, + chargepoint_id: UUID, payload: Any, suppress: bool = True, unique_id: Any | None = None @@ -27,5 +28,5 @@ async def call( except KeyError as e: raise e -def is_connected(chargepoint_id: str): +def is_connected(chargepoint_id: UUID): return chargepoint_id in __active_connections.keys() diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/chargepoint_v1.py b/app/routers/chargepoint_v1.py new file mode 100644 index 0000000..cd768b5 --- /dev/null +++ b/app/routers/chargepoint_v1.py @@ -0,0 +1,114 @@ +import random +import string +from uuid import UUID +from fastapi import APIRouter, HTTPException, Security +from fastapi.params import Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.ocpp_proto import chargepoint_manager +from app.schemas.chargepoint import ChargePoint, ChargePointCreate, ChargePointUpdate, ChargePointPassword, ChargePointConnectionInfo +from app.models.chargepoint import ChargePoint as DBChargePoint +from app.security import get_api_key + +router = APIRouter( + prefix="/chargepoint", + tags=["chargepoint"], +) + +@router.get(path="/", response_model=list[ChargePoint]) +async def get_chargepoints( + skip: int = 0, + limit: int = 20, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + return db.query(DBChargePoint).offset(skip).limit(limit).all() + +@router.get(path="/{chargepoint_id}", response_model=ChargePoint) +async def get_chargepoint( + chargepoint_id: UUID, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first() + if chargepoint is None: + raise HTTPException(status_code=404, detail="Chargepoint not found") + return chargepoint + +@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword) +async def get_chargepoint_password( + chargepoint_id: UUID, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first() + if chargepoint is None: + raise HTTPException(status_code=404, detail="Chargepoint not found") + return ChargePointPassword(password=chargepoint.password) + +@router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword) +async def reset_chargepoint_password( + chargepoint_id: UUID, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first() + if chargepoint is None: + raise HTTPException(status_code=404, detail="Chargepoint not found") + chargepoint.password = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(24)) + db.commit() + return ChargePointPassword(password=chargepoint.password) + +@router.post(path="/", response_model=ChargePoint) +async def create_chargepoint( + chargepoint: ChargePointCreate, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + chargepoint_db = DBChargePoint( + friendly_name=chargepoint.friendly_name, + is_active=chargepoint.is_active, + password=''.join(random.choice(string.ascii_letters + string.digits) for i in range(24)) + ) + db.add(chargepoint_db) + db.commit() + db.refresh(chargepoint_db) + return chargepoint_db + +@router.patch(path="/{chargepoint_id}", response_model=ChargePoint) +async def update_chargepoint( + chargepoint_id: UUID, + chargepoint_update: ChargePointUpdate, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first() + if chargepoint is None: + raise HTTPException(status_code=404, detail="Chargepoint not found") + for key, value in chargepoint_update.model_dump(exclude_unset=True).items(): + setattr(chargepoint, key, value) + db.commit() + return chargepoint + +@router.delete(path="/{chargepoint_id}", response_model=[]) +async def delete_chargepoint( + chargepoint_id: UUID, + api_key: str = Security(get_api_key), + db: Session = Depends(get_db) +): + chargepoint = db.query(DBChargePoint).filter(DBChargePoint.id == chargepoint_id).first() + if chargepoint is None: + raise HTTPException(status_code=404, detail="Chargepoint not found") + db.delete(chargepoint) + db.commit() + return [] + +@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo) +async def get_chargepoint_status( + chargepoint_id: UUID, + api_key: str = Security(get_api_key) +): + return ChargePointConnectionInfo( + connected=chargepoint_manager.is_connected(chargepoint_id) + ) diff --git a/src/routers/ocpp_v1.py b/app/routers/ocpp_v1.py similarity index 69% rename from src/routers/ocpp_v1.py rename to app/routers/ocpp_v1.py index 91e122a..3b8ca41 100644 --- a/src/routers/ocpp_v1.py +++ b/app/routers/ocpp_v1.py @@ -1,24 +1,25 @@ import logging from fastapi import APIRouter, WebSocket, WebSocketException -from ocpp_proto import chargepoint_manager -from ocpp_proto.chargepoint import ChargePoint -from util.websocket_wrapper import WebSocketWrapper + +from app.ocpp_proto import chargepoint_manager +from app.ocpp_proto.chargepoint import ChargePoint +from app.util.websocket_wrapper import WebSocketWrapper router = APIRouter() -@router.websocket("/{charging_station_id}") +@router.websocket("/{charging_station_friendly_name}") async def websocket_endpoint( *, websocket: WebSocket, - charging_station_id: str, + charging_station_friendly_name: str, ): """ For every new charging station that connects, create a ChargePoint instance and start listening for messages. """ - if (websocket.user.username != charging_station_id): + if (websocket.user.friendly_name != charging_station_friendly_name): raise WebSocketException(code=1008, reason="Username doesn't match chargepoint identifier") - logging.info("Charging station '%s' connected", charging_station_id) + logging.info("Charging station '%s' (%s) connected", charging_station_friendly_name, websocket.user.id) # Check protocols try: @@ -42,5 +43,5 @@ async def websocket_endpoint( # Accept connection and begin communication await websocket.accept(subprotocol="ocpp2.0.1") - cp = ChargePoint(charging_station_id, WebSocketWrapper(websocket)) - await chargepoint_manager.start(cp) + cp = ChargePoint(charging_station_friendly_name, WebSocketWrapper(websocket)) + await chargepoint_manager.start(websocket.user.id, cp) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/chargepoint.py b/app/schemas/chargepoint.py new file mode 100644 index 0000000..acb30b3 --- /dev/null +++ b/app/schemas/chargepoint.py @@ -0,0 +1,31 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel + +from app.schemas.connector import Connector + +class ChargePointBase(BaseModel): + friendly_name: str + is_active: bool + +class ChargePointUpdate(BaseModel): + friendly_name: Optional[str] = None + is_active: Optional[bool] = None + +class ChargePointCreate(ChargePointBase): + pass + +class ChargePoint(ChargePointBase): + id: UUID + last_seen: datetime | None + connectors: list[Connector] = [] + + class Config: + from_attributes = True + +class ChargePointPassword(BaseModel): + password: str + +class ChargePointConnectionInfo(BaseModel): + connected: bool diff --git a/src/schemas/connector.py b/app/schemas/connector.py similarity index 90% rename from src/schemas/connector.py rename to app/schemas/connector.py index 41a5645..786f9e7 100644 --- a/src/schemas/connector.py +++ b/app/schemas/connector.py @@ -1,4 +1,5 @@ import enum +from uuid import UUID from pydantic import BaseModel class ConnectorStatus(enum.Enum): @@ -9,7 +10,7 @@ class ConnectorStatus(enum.Enum): FAULTED = "Faulted" class Connector(BaseModel): - id: str + id: UUID evse: int index: int status: ConnectorStatus diff --git a/src/schemas/id_token.py b/app/schemas/id_token.py similarity index 77% rename from src/schemas/id_token.py rename to app/schemas/id_token.py index 2730a68..d279913 100644 --- a/src/schemas/id_token.py +++ b/app/schemas/id_token.py @@ -1,6 +1,7 @@ +from uuid import UUID from pydantic import BaseModel -from schemas.user import User +from app.schemas.user import User class IdTokenBase(BaseModel): title: str @@ -10,7 +11,7 @@ class IdTokenCreate(IdTokenBase): pass class IdToken(IdTokenBase): - id: str + id: UUID owner: User class Config: diff --git a/src/schemas/meter_value.py b/app/schemas/meter_value.py similarity index 98% rename from src/schemas/meter_value.py rename to app/schemas/meter_value.py index 830180f..5e31278 100644 --- a/src/schemas/meter_value.py +++ b/app/schemas/meter_value.py @@ -1,5 +1,6 @@ from datetime import datetime import enum +from uuid import UUID from pydantic import BaseModel class PhaseType(enum.Enum): @@ -42,7 +43,7 @@ class Measurand(enum.Enum): VOLTAGE = "Voltage" class MeterValue(BaseModel): - id: str + id: UUID timestamp: datetime measurand: Measurand phase_type: PhaseType diff --git a/src/schemas/transaction.py b/app/schemas/transaction.py similarity index 97% rename from src/schemas/transaction.py rename to app/schemas/transaction.py index 7723f6e..6937be7 100644 --- a/src/schemas/transaction.py +++ b/app/schemas/transaction.py @@ -1,5 +1,6 @@ from datetime import datetime import enum +from uuid import UUID from pydantic import BaseModel class TransactionStatus(enum.Enum): @@ -30,7 +31,7 @@ class TransactionEventTriggerReason(enum.Enum): RESET_COMMAND = "ResetCommand" class Transaction(BaseModel): - id: str + id: UUID status: TransactionStatus started_at: datetime ended_at: datetime diff --git a/src/schemas/user.py b/app/schemas/user.py similarity index 76% rename from src/schemas/user.py rename to app/schemas/user.py index eceedfe..c06817b 100644 --- a/src/schemas/user.py +++ b/app/schemas/user.py @@ -1,6 +1,7 @@ +from uuid import UUID from pydantic import BaseModel -from schemas.id_token import IdToken +from app.schemas.id_token import IdToken class UserBase(BaseModel): friendly_name: str @@ -10,7 +11,7 @@ class UserCreate(UserBase): pass class User(UserBase): - id: str + id: UUID id_tokens: list[IdToken] = [] class Config: diff --git a/src/security.py b/app/security.py similarity index 100% rename from src/security.py rename to app/security.py diff --git a/app/util/__init__.py b/app/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/util/websocket_auth_backend.py b/app/util/websocket_auth_backend.py similarity index 54% rename from src/util/websocket_auth_backend.py rename to app/util/websocket_auth_backend.py index 12802ea..a3260a6 100644 --- a/src/util/websocket_auth_backend.py +++ b/app/util/websocket_auth_backend.py @@ -1,9 +1,13 @@ import base64 import binascii +from uuid import UUID from starlette.authentication import ( AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser ) +from app.database import SessionLocal +from app.models.chargepoint import ChargePoint + class BasicAuthBackend(AuthenticationBackend): async def authenticate(self, conn): if "Authorization" not in conn.headers: @@ -19,5 +23,16 @@ class BasicAuthBackend(AuthenticationBackend): raise AuthenticationError('Invalid basic auth credentials') username, _, password = decoded.partition(":") - # TODO: You'd want to verify the username and password here. - return AuthCredentials(["authenticated"]), SimpleUser(username) \ No newline at end of file + try: + id = UUID(username) + except (ValueError) as exc: + raise AuthenticationError('Invalid basic auth credentials') + + with SessionLocal() as db: + chargepoint = db.query(ChargePoint).filter(ChargePoint.id == id).first() + if chargepoint is None: + raise AuthenticationError('Invalid basic auth credentials') + if chargepoint.password != password: + raise AuthenticationError('Invalid basic auth credentials') + + return AuthCredentials(["authenticated"]), chargepoint \ No newline at end of file diff --git a/src/util/websocket_wrapper.py b/app/util/websocket_wrapper.py similarity index 100% rename from src/util/websocket_wrapper.py rename to app/util/websocket_wrapper.py diff --git a/src/models/connector.py b/src/models/connector.py deleted file mode 100644 index 436faf2..0000000 --- a/src/models/connector.py +++ /dev/null @@ -1,14 +0,0 @@ -from sqlalchemy import Column, Enum, ForeignKey, Integer, String - -from schemas.connector import ConnectorStatus -from database import Base - -class Connector(Base): - __tablename__ = "connectors" - - id = Column(String, primary_key=True) - evse = Column(Integer) - index = Column(Integer) - status = Column(Enum(ConnectorStatus)) - - chargepoint_id = Column(String, ForeignKey("chargepoints.id")) diff --git a/src/models/id_token.py b/src/models/id_token.py deleted file mode 100644 index 32ead9d..0000000 --- a/src/models/id_token.py +++ /dev/null @@ -1,14 +0,0 @@ -from sqlalchemy import Boolean, Column, ForeignKey, String -from sqlalchemy.orm import relationship - -from database import Base - -class IdToken(Base): - __tablename__ = "id_tokens" - - id = Column(String, primary_key=True) - title = Column(String) - is_active = Column(Boolean, default=True) - - owner_id = Column(String, ForeignKey("users.id")) - owner = relationship("User", back_populates="id_tokens") diff --git a/src/models/meter_value.py b/src/models/meter_value.py deleted file mode 100644 index 3fe9ff4..0000000 --- a/src/models/meter_value.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, String - -from database import Base -from schemas.meter_value import Measurand, PhaseType - -class Transaction(Base): - __tablename__ = "meter_values" - - id = Column(String, primary_key=True) - timestamp = Column(DateTime, index=True) - measurand = Column(Enum(Measurand)) - phase_type = Column(Enum(PhaseType)) - unit = Column(String) - value = Column(Float) - - transaction_id = Column(String, ForeignKey("transactions.id", index=True)) \ No newline at end of file diff --git a/src/models/transaction.py b/src/models/transaction.py deleted file mode 100644 index d7ef02c..0000000 --- a/src/models/transaction.py +++ /dev/null @@ -1,19 +0,0 @@ -import enum -from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, String - -from schemas.transaction import TransactionEventTriggerReason, TransactionStatus -from database import Base - -class Transaction(Base): - __tablename__ = "transactions" - - id = Column(String, primary_key=True) - status = Column(Enum(TransactionStatus), index=True) - started_at = Column(DateTime, index=True) - ended_at = Column(DateTime, nullable=True, index=True) - meter_start = Column(Float) - meter_end = Column(Float, nullable=True) - end_reason = Column(Enum(TransactionEventTriggerReason)) - - connector_id = Column(String, ForeignKey("connectors.id", index=True)) - id_token_id = Column(String, ForeignKey("id_tokens.id", index= True)) diff --git a/src/models/user.py b/src/models/user.py deleted file mode 100644 index 8925954..0000000 --- a/src/models/user.py +++ /dev/null @@ -1,12 +0,0 @@ -from sqlalchemy import Boolean, Column, String -from sqlalchemy.orm import relationship - -from database import Base - -class User(Base): - __tablename__ = "users" - - id = Column(String, primary_key=True) - friendly_name = Column(String, unique=True, index=True) - is_active = Column(Boolean, default=True) - id_tokens = relationship("IdToken", back_populates="owner") diff --git a/src/ocpp_proto/chargepoint.py b/src/ocpp_proto/chargepoint.py deleted file mode 100644 index bcdda6a..0000000 --- a/src/ocpp_proto/chargepoint.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime, UTC -import os -from ocpp.routing import on -from ocpp.v201 import ChargePoint as cp -from ocpp.v201 import call_result -from ocpp.v201.enums import Action, RegistrationStatusType, AuthorizationStatusType - -class ChargePoint(cp): - @on(Action.BootNotification) - async def on_boot_notification(self, charging_station, reason, **kwargs): - return call_result.BootNotificationPayload( - current_time=datetime.now(UTC).isoformat(), - interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")), - status=RegistrationStatusType.accepted - ) - - @on(Action.Heartbeat) - async def on_heartbeat_request(self): - return call_result.HeartbeatPayload( - current_time=datetime.now(UTC).isoformat() - ) - - @on(Action.StatusNotification) - async def on_status_notification(self, evse_id, connector_id, connector_status, **kwargs): - return call_result.StatusNotificationPayload() - - @on(Action.Authorize) - async def on_authorize(self): - return call_result.AuthorizePayload(id_token_info={'status': AuthorizationStatusType.accepted, 'groupIdToken': ""}) - - @on(Action.TransactionEvent) - async def on_transaction_event(self): - return call_result.TransactionEventPayload() diff --git a/src/routers/chargepoint_v1.py b/src/routers/chargepoint_v1.py deleted file mode 100644 index c9ff823..0000000 --- a/src/routers/chargepoint_v1.py +++ /dev/null @@ -1,45 +0,0 @@ -from fastapi import APIRouter, Security -from ocpp_proto import chargepoint_manager -from schemas.chargepoint import ChargePoint -from security import get_api_key - -router = APIRouter( - prefix="/chargepoint", - tags=["chargepoint"], -) - -@router.get(path="/", response_model=list[ChargePoint]) -async def get_chargepoints(api_key: str = Security(get_api_key)): - return - -@router.post(path="/", response_model=ChargePoint) -async def create_chargepoint(api_key: str = Security(get_api_key)): - return - -@router.get(path="/{chargepoint_id}", response_model=ChargePoint) -async def get_chargepoint( - chargepoint_id: str, - api_key: str = Security(get_api_key), -): - return - -@router.patch(path="/{chargepoint_id}", response_model=ChargePoint) -async def update_chargepoint( - chargepoint_id: str, - api_key: str = Security(get_api_key), -): - return - -@router.delete(path="/{chargepoint_id}", response_model=[]) -async def delete_chargepoint( - chargepoint_id: str, - api_key: str = Security(get_api_key), -): - return - -@router.get(path="/{chargepoint_id}/status", response_model=bool) -async def get_chargepoint( - chargepoint_id: str, - api_key: str = Security(get_api_key), -): - return chargepoint_manager.is_connected(chargepoint_id) diff --git a/src/schemas/chargepoint.py b/src/schemas/chargepoint.py deleted file mode 100644 index 5c4aaad..0000000 --- a/src/schemas/chargepoint.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import datetime -from pydantic import BaseModel - -from schemas.connector import Connector - -class ChargePointBase(BaseModel): - friendly_name: str - is_active: bool - -class ChargePointCreate(ChargePointBase): - pass - -class ChargePoint(ChargePointBase): - id: str - password: str - last_seen: datetime - connectors: list[Connector] = [] - - class Config: - from_attributes = True \ No newline at end of file From 599a60c52b82e6a257f7221ccc083d2b23d9d5bc Mon Sep 17 00:00:00 2001 From: BluemediaGER Date: Sat, 13 Apr 2024 22:44:01 +0200 Subject: [PATCH 2/2] Add alembic --- alembic.ini | 113 +++++++++++++++++++++++++++++++++++++++++ alembic/env.py | 59 +++++++++++++++++++++ alembic/script.py.mako | 26 ++++++++++ poetry.lock | 109 ++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 5 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..3a46a67 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,113 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s-%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +timezone = UTC + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +output_encoding = utf-8 + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..8d40e79 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,59 @@ +from logging.config import fileConfig +import os + +from sqlalchemy import create_engine +from alembic import context + +# Import models for autogenerate support +from app.database import Base +from app.models import * + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db") + + if SQLALCHEMY_DATABASE_URL.startswith("sqlite"): + connectable = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} + ) + else: + connectable = create_engine(SQLALCHEMY_DATABASE_URL) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + raise NotImplementedError("Offline migration is not supported") +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/poetry.lock b/poetry.lock index 23614ad..07231f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,24 @@ # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +[[package]] +name = "alembic" +version = "1.13.1" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"}, + {file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["backports.zoneinfo"] + [[package]] name = "annotated-types" version = "0.6.0" @@ -270,6 +289,94 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "mako" +version = "1.3.3" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.3-py3-none-any.whl", hash = "sha256:5324b88089a8978bf76d1629774fcc2f1c07b82acdf00f4c5dd8ceadfffc4b40"}, + {file = "Mako-1.3.3.tar.gz", hash = "sha256:e16c01d9ab9c11f7290eef1cfefc093fb5a45ee4a3da09e2fec2e4d1bae54e73"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + [[package]] name = "ocpp" version = "0.26.0" @@ -957,4 +1064,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "599bf05928701049ff4348ad877ab3413c9864c2a5bf41ae047c7bbe0704270f" +content-hash = "2b9d4a1a7ceaa46c78814156552aea406a0683f55a574d198d8a65917b476e40" diff --git a/pyproject.toml b/pyproject.toml index d73594f..c4ccb64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ fastapi = "^0.110.0" uvicorn = {extras = ["standard"], version = "^0.28.0"} websockets = "^12.0" sqlalchemy = "^2.0.28" +alembic = "^1.13.1" [build-system] requires = ["poetry-core"]